feat(consul): add Consul full-stack workbench
This commit is contained in:
148 files changed
+24032
-378
No files matched your search
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>favicon</title>
|
||||
<g id="favicon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M19.5795647,2.46534761 C17.3769619,1.88281475 15.0654136,1.84664567 12.8456739,2.35998035 C10.6258805,2.8733161 8.56493925,3.92063891 6.8417186,5.41105013 C5.11848183,6.90146135 3.78503178,8.78992153 2.95709359,10.9125342 C2.12916239,13.0350931 1.83176938,15.327678 2.09073006,17.5912538 C2.34969127,19.8548296 3.15718174,22.0210097 4.44311046,23.9017717 C5.72903917,25.7825337 7.45453221,27.3210411 9.46986459,28.3837441 C11.485197,29.4464472 13.7294333,30.0012747 16.0077822,30 C19.5196661,30.0035846 22.9038559,28.6829685 25.4850822,26.3016369 L22.174651,22.8413249 C20.8533901,24.0326353 19.215016,24.8152386 17.4580265,25.0944251 C15.701037,25.3736654 13.9007486,25.1374017 12.2753211,24.4143747 C10.6498399,23.691294 9.2688417,22.5124467 8.2996664,21.0205689 C7.33049109,19.5287449 6.81465948,17.7878716 6.81465948,16.0088029 C6.81465948,14.2297879 7.33049109,12.4889146 8.2996664,10.9970368 C9.2688417,9.50515904 10.6498399,8.32631181 12.2753211,7.6032848 C13.9007486,6.88022555 15.701037,6.64399411 17.4580265,6.923186 C19.215016,7.2023779 20.8533901,7.98502415 22.174651,9.17633458 L25.4850822,5.70276969 C23.8093184,4.15917497 21.7821675,3.04788154 19.5795647,2.46534761 Z M27.0510408,20.7255348 C26.8287445,20.769747 26.6244984,20.8789074 26.4642495,21.0391563 C26.3039469,21.1994589 26.1947865,21.4036513 26.1505743,21.6260013 C26.1063622,21.8482976 26.1290323,22.0787595 26.2157913,22.2881628 C26.3025502,22.4976198 26.4494226,22.6766172 26.6379286,22.8025386 C26.8263808,22.9284599 27.0479788,22.9956645 27.2746264,22.9956645 C27.5786319,22.9956645 27.8701205,22.8749541 28.085057,22.6600175 C28.2999936,22.445081 28.4207577,22.1535387 28.4207577,21.8495869 C28.4207577,21.6228855 28.3534994,21.4013413 28.2275781,21.2128354 C28.1016567,21.0243831 27.9226593,20.877457 27.713256,20.7907517 C27.503799,20.7039928 27.2733908,20.6812689 27.0510408,20.7255348 Z M28.3691321,16.8066091 C28.1468358,16.850875 27.9425897,16.9599818 27.7823408,17.1202843 C27.6220382,17.2805869 27.5128778,17.4847794 27.4686656,17.7071294 C27.4244535,17.9294257 27.4471236,18.1598875 27.5338826,18.3692908 C27.6206415,18.5786941 27.7675139,18.7576915 27.9560199,18.8836666 C28.1444721,19.0095879 28.3660701,19.0767925 28.5927177,19.0767925 C28.8963472,19.0756107 29.1871911,18.9545242 29.401859,18.7398563 C29.616527,18.5251346 29.7376672,18.2342907 29.738849,17.930715 C29.738849,17.7040136 29.6716444,17.4824156 29.5456694,17.2939634 C29.419748,17.1055111 29.2407506,16.958585 29.0313473,16.8718261 C28.8218903,16.7851208 28.5914822,16.762397 28.3691321,16.8066091 Z M17.0809071,13.1984577 C16.5267243,12.9689629 15.9169405,12.9089031 15.328645,13.0259068 C14.7403496,13.1429105 14.199973,13.4317668 13.775848,13.8558918 C13.3516693,14.2800168 13.0628667,14.8204471 12.9458093,15.4086889 C12.8288056,15.9969843 12.8888654,16.6067681 13.1184139,17.1609509 C13.3479625,17.7151337 13.7366856,18.1887892 14.2354286,18.5220188 C14.734118,18.8552483 15.3204795,19.0331176 15.9203249,19.0331176 C16.7246314,19.0331176 17.496007,18.7135868 18.0648019,18.1448457 C18.633543,17.5761046 18.9530738,16.8047289 18.9530738,16.0003687 C18.9530738,15.400577 18.7752045,14.8142155 18.4419213,14.3154724 C18.1086917,13.8167294 17.6350362,13.4280063 17.0809071,13.1984577 Z M25.619384,16.7397806 C25.4099806,16.6530216 25.1795188,16.6303515 24.9572225,16.6745636 C24.7348725,16.7187758 24.5306801,16.8279362 24.3703775,16.9882388 C24.2101286,17.1484877 24.1009681,17.3527338 24.0567023,17.5750301 C24.0124901,17.7973802 24.035214,18.0277883 24.1219729,18.2372453 C24.2086782,18.4466486 24.3556043,18.625646 24.5440565,18.7515674 C24.7325625,18.8774887 24.9541067,18.944747 25.1808081,18.944747 C25.4847599,18.944747 25.7763021,18.8239829 25.9912387,18.6090463 C26.2061215,18.3941098 26.3268857,18.1026212 26.3268857,17.7986157 C26.3268857,17.5719681 26.2596811,17.3503701 26.1337597,17.1619179 C26.0078384,16.9734119 25.8287873,16.8265395 25.619384,16.7397806 Z M25.619384,13.1472082 C25.4099806,13.0604492 25.1795188,13.0377791 24.9572225,13.0819912 C24.7348725,13.1262034 24.5306801,13.2353639 24.3703775,13.3956665 C24.2101286,13.5559153 24.1009681,13.7601615 24.0567023,13.9824578 C24.0124901,14.2048078 24.035214,14.4352159 24.1219729,14.6446729 C24.2086782,14.8540763 24.3556043,15.0330737 24.5440565,15.158995 C24.7325625,15.2849163 24.9541067,15.3521747 25.1808081,15.3521747 C25.4847599,15.3521747 25.7763021,15.2314105 25.9912387,15.016474 C26.2061215,14.8015374 26.3268857,14.5099951 26.3268857,14.2060434 C26.3268857,13.9793957 26.2596811,13.7577978 26.1337597,13.5692918 C26.0078384,13.3808396 25.8287873,13.2339671 25.619384,13.1472082 Z M28.3691321,12.9496234 C28.1468358,12.9938893 27.9425897,13.1030498 27.7823408,13.2632986 C27.6220382,13.4236012 27.5128778,13.6277936 27.4686656,13.8501437 C27.4244535,14.07244 27.4471236,14.3029018 27.5338826,14.5123051 C27.6206415,14.7217622 27.7675139,14.9007058 27.9560199,15.0266809 C28.1444721,15.1526022 28.3660701,15.2198068 28.5927177,15.2198068 C2Line truncated
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
@@ -1546,7 +1546,7 @@ async function openConnectionQuery(connectionId: string) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper") {
|
||||
if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper" || initialTarget.kind === "consul") {
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
queryStore.createTab(connectionId, "", `${connection.name}:keys`, initialTarget.kind);
|
||||
@@ -1968,6 +1968,8 @@ async function handleQuickOpenSelect(item: any) {
|
||||
await connectionStore.loadEtcdRoot(item.connectionId);
|
||||
} else if (config?.db_type === "zookeeper") {
|
||||
await connectionStore.loadZooKeeperRoot(item.connectionId);
|
||||
} else if (config?.db_type === "consul") {
|
||||
await connectionStore.loadConsulRoot(item.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(item.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
@@ -1993,6 +1995,8 @@ async function handleQuickOpenSelect(item: any) {
|
||||
await connectionStore.loadEtcdRoot(item.connectionId);
|
||||
} else if (config?.db_type === "zookeeper") {
|
||||
await connectionStore.loadZooKeeperRoot(item.connectionId);
|
||||
} else if (config?.db_type === "consul") {
|
||||
await connectionStore.loadConsulRoot(item.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(item.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
|
||||
@@ -53,6 +53,7 @@ import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriv
|
||||
import { JDBCX_DEFAULT_URL, JDBCX_DRIVER_PROFILE, JDBCX_JDBC_DRIVER_CLASS, ensureJdbcxRuntimeDrivers, isJdbcxRuntimeBundle, isJdbcxRuntimePath, jdbcxHighPrivilegeExtensionsEnabled, setJdbcxHighPrivilegeExtensionsEnabled } from "@/lib/database/jdbcxBuiltinDriver";
|
||||
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection";
|
||||
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout";
|
||||
import { consulAgentAddressesMatch } from "@/lib/consul/agentTarget";
|
||||
import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints";
|
||||
import { preventDialogDocumentSelectAll } from "@/lib/connection/dialogTextSelection";
|
||||
import { postgresTlsModeForForm } from "@/lib/connection/postgresTlsMode";
|
||||
@@ -809,6 +810,22 @@ const nacosTlsSkipVerify = ref(false);
|
||||
const nacosMetricsMode = ref<NacosMetricsMode>("auto");
|
||||
const nacosMetricsUrl = ref("");
|
||||
const nacosPageSize = ref(20);
|
||||
type ConsulConsistency = "default" | "stale" | "consistent";
|
||||
const consulServerAddr = ref("http://127.0.0.1:8500");
|
||||
const consulDatacenter = ref("");
|
||||
const consulNamespace = ref("");
|
||||
const consulPartition = ref("");
|
||||
const consulConsistency = ref<ConsulConsistency>("default");
|
||||
const consulTlsSkipVerify = ref(false);
|
||||
const consulAgentTargetNode = ref("");
|
||||
const consulAgentTargetAddress = ref("");
|
||||
const consulMeshVisible = ref(false);
|
||||
const consulOperatorVisible = ref(false);
|
||||
const consulOperatorSnapshotRestoreEnabled = ref(false);
|
||||
const consulOperatorAutopilotWriteEnabled = ref(false);
|
||||
const consulOperatorRaftWriteEnabled = ref(false);
|
||||
const consulOperatorKeyringWriteEnabled = ref(false);
|
||||
const consulOperatorLicenseWriteEnabled = ref(false);
|
||||
|
||||
// --- MQTT-specific form fields ---
|
||||
const mqttHost = ref("127.0.0.1");
|
||||
@@ -1131,6 +1148,7 @@ const driverProfiles: Record<
|
||||
rocketmq: { type: "mq", port: 9876, user: "", label: "Apache RocketMQ", icon: "rocketmq", host: "127.0.0.1" },
|
||||
rabbitmq: { type: "mq", port: 5672, user: "", label: "RabbitMQ", icon: "rabbitmq", host: "127.0.0.1" },
|
||||
nacos: { type: "nacos", port: 8848, user: "nacos", label: "Nacos", icon: "nacos", host: "127.0.0.1" },
|
||||
consul: { type: "consul", port: 8500, user: "", label: "Consul", icon: "consul", host: "127.0.0.1" },
|
||||
mqtt: { type: "mqtt", port: 1883, user: "", label: "MQTT", icon: "mqtt", host: "127.0.0.1" },
|
||||
iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" },
|
||||
influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" },
|
||||
@@ -1351,6 +1369,31 @@ function hydrateNacosFields(value: unknown) {
|
||||
resetNacosFields(value as Partial<NacosAdminConfig>);
|
||||
}
|
||||
|
||||
function resetConsulFields(value?: Record<string, unknown>) {
|
||||
consulServerAddr.value = String(value?.serverAddr || value?.server_addr || "http://127.0.0.1:8500");
|
||||
consulDatacenter.value = String(value?.datacenter || value?.consulDatacenter || value?.consul_datacenter || "");
|
||||
consulNamespace.value = String(value?.namespace || value?.consulNamespace || value?.consul_namespace || "");
|
||||
consulPartition.value = String(value?.partition || value?.consulPartition || value?.consul_partition || "");
|
||||
const consistency = String(value?.consistency || value?.consulConsistency || value?.consul_consistency || "default");
|
||||
consulConsistency.value = (["default", "stale", "consistent"].includes(consistency) ? consistency : "default") as ConsulConsistency;
|
||||
consulTlsSkipVerify.value = Boolean(value?.tlsSkipVerify || value?.tls_skip_verify || value?.consulTlsSkipVerify || value?.consul_tls_skip_verify);
|
||||
const agentTarget = value?.agentTarget || value?.agent_target;
|
||||
const target = agentTarget && typeof agentTarget === "object" ? (agentTarget as Record<string, unknown>) : undefined;
|
||||
consulAgentTargetNode.value = String(target?.node || "");
|
||||
consulAgentTargetAddress.value = String(target?.address || "");
|
||||
consulMeshVisible.value = Boolean(value?.consulMeshVisible);
|
||||
consulOperatorVisible.value = Boolean(value?.consulOperatorVisible);
|
||||
consulOperatorSnapshotRestoreEnabled.value = Boolean(value?.consulOperatorSnapshotRestoreEnabled);
|
||||
consulOperatorAutopilotWriteEnabled.value = Boolean(value?.consulOperatorAutopilotWriteEnabled);
|
||||
consulOperatorRaftWriteEnabled.value = Boolean(value?.consulOperatorRaftWriteEnabled);
|
||||
consulOperatorKeyringWriteEnabled.value = Boolean(value?.consulOperatorKeyringWriteEnabled);
|
||||
consulOperatorLicenseWriteEnabled.value = Boolean(value?.consulOperatorLicenseWriteEnabled);
|
||||
}
|
||||
|
||||
function hydrateConsulFields(value: unknown) {
|
||||
resetConsulFields(value && typeof value === "object" ? (value as Record<string, unknown>) : undefined);
|
||||
}
|
||||
|
||||
function resetMqttFields(config?: Partial<MqttConnectionConfig>) {
|
||||
mqttHost.value = config?.host?.trim() || "127.0.0.1";
|
||||
mqttPort.value = config?.port || 1883;
|
||||
@@ -1685,6 +1728,46 @@ function buildNacosAdminConfig(): NacosAdminConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function buildConsulExternalConfig(): Record<string, unknown> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(consulServerAddr.value.trim());
|
||||
} catch {
|
||||
throw new Error(t("connection.consulAddressInvalid"));
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(t("connection.consulAddressInvalid"));
|
||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error(t("connection.consulAddressInvalid"));
|
||||
const targetNode = consulAgentTargetNode.value.trim();
|
||||
const targetAddress = consulAgentTargetAddress.value.trim();
|
||||
if (!!targetNode !== !!targetAddress) throw new Error(t("connection.consulAgentTargetIncomplete"));
|
||||
if (targetAddress && !consulAgentAddressesMatch(targetAddress, parsed.hostname)) {
|
||||
throw new Error(t("connection.consulAgentTargetAddressMismatch"));
|
||||
}
|
||||
return {
|
||||
serverAddr: parsed.toString().replace(/\/$/, ""),
|
||||
datacenter: consulDatacenter.value.trim() || undefined,
|
||||
namespace: consulNamespace.value.trim() || undefined,
|
||||
partition: consulPartition.value.trim() || undefined,
|
||||
consistency: consulConsistency.value,
|
||||
tlsSkipVerify: consulTlsSkipVerify.value || undefined,
|
||||
agentTarget: targetNode ? { node: targetNode, address: targetAddress } : undefined,
|
||||
consulMeshVisible: consulMeshVisible.value || undefined,
|
||||
consulOperatorVisible: consulOperatorVisible.value || undefined,
|
||||
consulOperatorSnapshotRestoreEnabled: consulOperatorSnapshotRestoreEnabled.value || undefined,
|
||||
consulOperatorAutopilotWriteEnabled: consulOperatorAutopilotWriteEnabled.value || undefined,
|
||||
consulOperatorRaftWriteEnabled: consulOperatorRaftWriteEnabled.value || undefined,
|
||||
consulOperatorKeyringWriteEnabled: consulOperatorKeyringWriteEnabled.value || undefined,
|
||||
consulOperatorLicenseWriteEnabled: consulOperatorLicenseWriteEnabled.value || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function applyConsulServerAddr(config: LegacyConnectionConfig, serverAddr: string) {
|
||||
const parsed = new URL(serverAddr);
|
||||
config.host = parsed.hostname;
|
||||
config.port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8500);
|
||||
config.ssl = parsed.protocol === "https:";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
@@ -2262,6 +2345,14 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
||||
form.value.connection_string = undefined;
|
||||
form.value.url_params = "";
|
||||
}
|
||||
if (profile.type === "consul") {
|
||||
resetConsulFields();
|
||||
form.value.database = undefined;
|
||||
form.value.connection_string = undefined;
|
||||
form.value.username = "";
|
||||
form.value.password = "";
|
||||
form.value.url_params = "";
|
||||
}
|
||||
if (profile.type === "mqtt") {
|
||||
resetMqttFields();
|
||||
form.value.database = undefined;
|
||||
@@ -2384,6 +2475,11 @@ watch(
|
||||
} else {
|
||||
resetNacosFields();
|
||||
}
|
||||
if (config.db_type === "consul") {
|
||||
hydrateConsulFields(config.external_config);
|
||||
} else {
|
||||
resetConsulFields();
|
||||
}
|
||||
if (config.db_type === "mqtt") {
|
||||
hydrateMqttFields(config.external_config);
|
||||
} else {
|
||||
@@ -2640,6 +2736,7 @@ const iconTypeMap: Record<string, string> = {
|
||||
rocketmq: "rocketmq",
|
||||
rabbitmq: "rabbitmq",
|
||||
nacos: "nacos",
|
||||
consul: "consul",
|
||||
mqtt: "mqtt",
|
||||
dm: "dm",
|
||||
h2: "h2",
|
||||
@@ -2745,6 +2842,7 @@ const dbOptions: DbOption[] = [
|
||||
{ value: "rabbitmq", label: "RabbitMQ" },
|
||||
{ value: "mqtt", label: "MQTT" },
|
||||
{ value: "nacos", label: "Nacos" },
|
||||
{ value: "consul", label: "Consul" },
|
||||
{ value: "influxdb", label: "InfluxDB" },
|
||||
{ value: "victoriametrics", label: "VictoriaMetrics" },
|
||||
{ value: "iris", label: "IRIS" },
|
||||
@@ -2805,7 +2903,7 @@ const dbCategoryDefinitions: Array<{
|
||||
{
|
||||
key: "registry_config",
|
||||
titleKey: "connection.databaseCategoryRegistryConfig",
|
||||
optionValues: ["etcd", "zookeeper", "nacos"],
|
||||
optionValues: ["etcd", "zookeeper", "nacos", "consul"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2921,6 +3019,7 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
|
||||
"dameng",
|
||||
"redis",
|
||||
"etcd",
|
||||
"consul",
|
||||
"clickhouse",
|
||||
"elasticsearch",
|
||||
"easysearch",
|
||||
@@ -3279,6 +3378,7 @@ const hasRequiredConnectionTarget = computed(() => {
|
||||
if (form.value.db_type === "zookeeper") return !!(form.value.host || form.value.connection_string || connectionUrlInput.value.trim());
|
||||
if (form.value.db_type === "mqtt") return !!mqttHost.value.trim() && mqttPort.value > 0;
|
||||
if (form.value.db_type === "nacos") return !!nacosServerAddr.value.trim();
|
||||
if (form.value.db_type === "consul") return !!consulServerAddr.value.trim();
|
||||
if (isCloudflareD1Connection(form.value)) return hasCloudflareD1Credentials(form.value);
|
||||
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
|
||||
return !!(form.value.host || (mongoUseUrl.value && form.value.connection_string) || (form.value.db_type === "jdbc" && form.value.connection_string) || connectionUrlInput.value.trim());
|
||||
@@ -3679,6 +3779,20 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
config.database = undefined;
|
||||
config.connection_string = undefined;
|
||||
config.url_params = "";
|
||||
} else if (config.db_type === "consul") {
|
||||
const consulConfig = buildConsulExternalConfig();
|
||||
config.external_config = consulConfig;
|
||||
applyConsulServerAddr(config, String(consulConfig.serverAddr));
|
||||
config.client_cert_path = config.client_cert_path?.trim() || "";
|
||||
config.client_key_path = config.client_key_path?.trim() || "";
|
||||
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
|
||||
throw new Error(t("connection.etcdClientCertPairRequired"));
|
||||
}
|
||||
config.username = "";
|
||||
config.password = config.password.trim();
|
||||
config.database = undefined;
|
||||
config.connection_string = undefined;
|
||||
config.url_params = "";
|
||||
} else if (config.db_type === "mqtt") {
|
||||
const mqttConfig = buildMqttExternalConfig();
|
||||
config.external_config = mqttConfig;
|
||||
@@ -3813,12 +3927,12 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
|
||||
throw new Error(t("connection.etcdClientCertPairRequired"));
|
||||
}
|
||||
} else {
|
||||
} else if (form.value.db_type !== "consul") {
|
||||
config.etcd_endpoints = undefined;
|
||||
config.client_cert_path = undefined;
|
||||
config.client_key_path = undefined;
|
||||
}
|
||||
if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd" && config.db_type !== "starrocks" && config.db_type !== "mongodb" && config.db_type !== "victoriametrics") {
|
||||
if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd" && config.db_type !== "consul" && config.db_type !== "starrocks" && config.db_type !== "mongodb" && config.db_type !== "victoriametrics") {
|
||||
config.ca_cert_path = undefined;
|
||||
} else {
|
||||
config.ca_cert_path = config.ca_cert_path?.trim() || "";
|
||||
@@ -5442,7 +5556,7 @@ function openExternalUrl(url: string) {
|
||||
|
||||
<TabsContent value="connection" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6" :class="{ 'connection-form-body--nacos': form.db_type === 'nacos' }">
|
||||
<div v-if="!isJdbcConnection && form.db_type !== 'nacos'" class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="!isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="connectionUrlInput" class="flex-1" :placeholder="connectionUrlPlaceholder" @keydown.enter.prevent="applyConnectionUrl" />
|
||||
@@ -6174,6 +6288,80 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Consul KV: HTTP endpoint, ACL token and scope -->
|
||||
<template v-else-if="form.db_type === 'consul'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulAddress") }}</Label>
|
||||
<Input v-model="consulServerAddr" class="col-span-3" placeholder="http://127.0.0.1:8500" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulToken") }}</Label>
|
||||
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.consulTokenPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulDatacenter") }}</Label>
|
||||
<Input v-model="consulDatacenter" class="col-span-3" placeholder="dc1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulNamespace") }}</Label>
|
||||
<Input v-model="consulNamespace" class="col-span-3" placeholder="default" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulPartition") }}</Label>
|
||||
<Input v-model="consulPartition" class="col-span-3" placeholder="default" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.consulAgentTargetNode") }}</Label>
|
||||
<Input v-model="consulAgentTargetNode" class="col-span-3" placeholder="consul-client-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.consulAgentTargetAddress") }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<Input v-model="consulAgentTargetAddress" placeholder="127.0.0.1" />
|
||||
<p class="text-xs text-muted-foreground">{{ t("connection.consulAgentTargetHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulConsistency") }}</Label>
|
||||
<Select v-model="consulConsistency">
|
||||
<SelectTrigger class="col-span-3 h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{{ t("connection.consulConsistencyDefault") }}</SelectItem>
|
||||
<SelectItem value="stale">{{ t("connection.consulConsistencyStale") }}</SelectItem>
|
||||
<SelectItem value="consistent">{{ t("connection.consulConsistencyConsistent") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulTlsSkipVerify") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<Switch v-model="consulTlsSkipVerify" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.consulTlsSkipVerifyHint") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.consulMeshFeatures") }}</Label>
|
||||
<div class="col-span-3 flex items-start justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium">{{ t("connection.consulMeshVisible") }}</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("connection.consulMeshVisibleHint") }}</p>
|
||||
</div>
|
||||
<Switch v-model="consulMeshVisible" class="mt-0.5 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.consulOperatorWrites") }}</Label>
|
||||
<div class="col-span-3 grid gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs">
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorVisible" type="checkbox" />{{ t("connection.consulOperatorVisible") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorSnapshotRestoreEnabled" type="checkbox" />{{ t("connection.consulOperatorSnapshotRestore") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorAutopilotWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorAutopilot") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorRaftWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorRaft") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorKeyringWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorKeyring") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorLicenseWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorLicense") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- etcd: endpoints, user, password, TLS -->
|
||||
<template v-else-if="form.db_type === 'etcd'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
@@ -6997,7 +7185,7 @@ function openExternalUrl(url: string) {
|
||||
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2 pb-6">
|
||||
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions" class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions && form.db_type !== 'consul'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">SSL/TLS</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="tlsEnabled" class="mr-0" />
|
||||
@@ -7050,7 +7238,7 @@ function openExternalUrl(url: string) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="form.db_type === 'etcd'">
|
||||
<template v-if="form.db_type === 'etcd' || form.db_type === 'consul'">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallPaddedClass">
|
||||
<span class="inline-flex items-center justify-end gap-1">
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { Clipboard, Eye, EyeOff, Loader2, Pencil, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulAclItem, ConsulAclKind, ConsulAclReferences, ConsulAclWrite, ConsulCapabilities } from "@/types/consul";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const active = ref<ConsulAclKind>("token");
|
||||
const items = ref<Record<string, unknown>[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const editorOpen = ref(false);
|
||||
const editorId = ref<string | null>(null);
|
||||
const editorJson = ref("{}");
|
||||
const editorOriginalJson = ref("");
|
||||
const savePreviewOpen = ref(false);
|
||||
const references = ref<ConsulAclReferences | null>(null);
|
||||
const editReferences = ref<ConsulAclReferences | null>(null);
|
||||
const pendingDelete = ref<{ id: string; label: string; kind: ConsulAclKind } | null>(null);
|
||||
const secretOnce = ref("");
|
||||
const secretRevealed = ref(false);
|
||||
const operationBusy = ref(false);
|
||||
let loadSequence = 0;
|
||||
|
||||
const tabs = computed<{ value: ConsulAclKind; label: string }[]>(() => [
|
||||
{ value: "token", label: t("consul.ui.tokens") },
|
||||
{ value: "policy", label: t("consul.ui.policies") },
|
||||
{ value: "role", label: t("consul.ui.roles") },
|
||||
{ value: "authMethod", label: t("consul.ui.authMethods") },
|
||||
{ value: "bindingRule", label: t("consul.ui.bindingRules") },
|
||||
{ value: "templatedPolicy", label: t("consul.ui.templatedPolicies") },
|
||||
]);
|
||||
const activeLabel = computed(() => tabs.value.find((tab) => tab.value === active.value)?.label || active.value);
|
||||
function capabilityLabel(status?: string) {
|
||||
if (status === "supported") return t("consul.ui.capabilitySupported");
|
||||
if (status === "unsupported") return t("consul.ui.capabilityUnsupported");
|
||||
if (status === "disabled") return t("consul.ui.capabilityDisabled");
|
||||
if (status === "forbidden") return t("consul.ui.capabilityForbidden");
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const capability = computed(() => (active.value === "authMethod" ? props.capabilities?.authMethods : active.value === "bindingRule" ? props.capabilities?.bindingRules : active.value === "templatedPolicy" ? props.capabilities?.templatedPolicies : props.capabilities?.acl));
|
||||
const canWrite = computed(() => !readOnly.value && capability.value === "supported" && active.value !== "templatedPolicy");
|
||||
|
||||
function itemId(item: Record<string, unknown>): string {
|
||||
return String(item.AccessorID || item.ID || item.Name || item.TemplateName || "");
|
||||
}
|
||||
function itemLabel(item: Record<string, unknown>): string {
|
||||
return String(item.Name || item.Description || item.AccessorID || item.ID || item.TemplateName || "-");
|
||||
}
|
||||
function clearSecret() {
|
||||
secretOnce.value = "";
|
||||
secretRevealed.value = false;
|
||||
}
|
||||
function scrubSecret(item: Record<string, unknown>) {
|
||||
if (typeof item.SecretID === "string" && item.SecretID) {
|
||||
secretOnce.value = item.SecretID;
|
||||
secretRevealed.value = false;
|
||||
delete item.SecretID;
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
const sequence = ++loadSequence;
|
||||
const connectionId = props.connectionId;
|
||||
const kind = active.value;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
items.value = [];
|
||||
try {
|
||||
const result = await api.consulAclList(connectionId, kind);
|
||||
if (sequence !== loadSequence || connectionId !== props.connectionId || kind !== active.value) return;
|
||||
items.value = (result.items as Record<string, unknown>[]).map((item) => {
|
||||
const safe = { ...item };
|
||||
delete safe.SecretID;
|
||||
return safe;
|
||||
});
|
||||
} catch (cause) {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && kind === active.value) error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && kind === active.value) loading.value = false;
|
||||
}
|
||||
}
|
||||
function openCreate() {
|
||||
editorId.value = null;
|
||||
editorOriginalJson.value = "";
|
||||
editorJson.value = JSON.stringify(defaultItem(active.value), null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
async function openEdit(item: Record<string, unknown>) {
|
||||
const id = itemId(item);
|
||||
editorId.value = id;
|
||||
if (active.value === "templatedPolicy") {
|
||||
editorJson.value = JSON.stringify(item, null, 2);
|
||||
editorOriginalJson.value = editorJson.value;
|
||||
editReferences.value = null;
|
||||
editorOpen.value = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [result, impact]: [ConsulAclItem, ConsulAclReferences | null] = await Promise.all([
|
||||
api.consulAclGet(props.connectionId, active.value, id),
|
||||
active.value === "policy" || active.value === "role" || active.value === "authMethod" ? api.consulAclReferences(props.connectionId, active.value, id) : Promise.resolve(null),
|
||||
]);
|
||||
editReferences.value = impact;
|
||||
const value = { ...(result.item as Record<string, unknown>) };
|
||||
delete value.SecretID;
|
||||
editorJson.value = JSON.stringify(value, null, 2);
|
||||
editorOriginalJson.value = editorJson.value;
|
||||
editorOpen.value = true;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
error.value = "";
|
||||
try {
|
||||
JSON.parse(editorJson.value);
|
||||
savePreviewOpen.value = true;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function confirmSave() {
|
||||
error.value = "";
|
||||
try {
|
||||
const item = JSON.parse(editorJson.value) as Record<string, unknown>;
|
||||
delete item.SecretID;
|
||||
const value = { kind: active.value, item } as ConsulAclWrite;
|
||||
const result = await api.consulAclApply(props.connectionId, editorId.value, value);
|
||||
scrubSecret(result.item as Record<string, unknown>);
|
||||
savePreviewOpen.value = false;
|
||||
editorOpen.value = false;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function runOperation(action: () => Promise<void>) {
|
||||
if (operationBusy.value) return;
|
||||
operationBusy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await action();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
operationBusy.value = false;
|
||||
}
|
||||
}
|
||||
async function inspectDelete(item: Record<string, unknown>) {
|
||||
const id = itemId(item);
|
||||
const label = itemLabel(item);
|
||||
const kind = active.value;
|
||||
const connectionId = props.connectionId;
|
||||
await runOperation(async () => {
|
||||
references.value = await api.consulAclReferences(connectionId, kind, id);
|
||||
pendingDelete.value = { id, label, kind };
|
||||
});
|
||||
}
|
||||
async function cloneToken(item: Record<string, unknown>) {
|
||||
const description = window.prompt(t("consul.ui.cloneTokenDescription"), String(item.Description || ""));
|
||||
if (description === null) return;
|
||||
await runOperation(async () => {
|
||||
const result = await api.consulAclTokenClone(props.connectionId, itemId(item), description);
|
||||
scrubSecret(result as Record<string, unknown>);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function remove() {
|
||||
const pending = pendingDelete.value;
|
||||
if (!pending || !references.value?.complete) return;
|
||||
await runOperation(async () => {
|
||||
await api.consulAclDelete(props.connectionId, pending.kind, pending.id);
|
||||
pendingDelete.value = null;
|
||||
references.value = null;
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function copySecret() {
|
||||
if (!secretOnce.value) return;
|
||||
await runOperation(async () => {
|
||||
await navigator.clipboard.writeText(secretOnce.value);
|
||||
clearSecret();
|
||||
});
|
||||
}
|
||||
function closeDelete() {
|
||||
pendingDelete.value = null;
|
||||
references.value = null;
|
||||
}
|
||||
function defaultItem(kind: ConsulAclKind): Record<string, unknown> {
|
||||
if (kind === "token") return { Description: "", Local: false, Policies: [], Roles: [] };
|
||||
if (kind === "policy") return { Name: "", Description: "", Rules: "", Datacenters: [] };
|
||||
if (kind === "role") return { Name: "", Description: "", Policies: [] };
|
||||
if (kind === "authMethod") return { Name: "", Type: "jwt", DisplayName: "", Description: "", Config: {} };
|
||||
return { Description: "", AuthMethod: "", Selector: "", BindType: "service", BindName: "" };
|
||||
}
|
||||
watch([active, () => props.connectionId], () => {
|
||||
clearSecret();
|
||||
void load();
|
||||
});
|
||||
onMounted(() => void load());
|
||||
onDeactivated(clearSecret);
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
clearSecret();
|
||||
});
|
||||
defineExpose({
|
||||
refresh: () => {
|
||||
void load();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<Tabs v-model="active" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex h-11 items-center justify-between border-b px-3">
|
||||
<TabsList class="h-8 p-0.5"
|
||||
><TabsTrigger v-for="tab in tabs" :key="tab.value" :value="tab.value" class="h-7 px-2 text-xs">{{ tab.label }}</TabsTrigger></TabsList
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="loading || operationBusy" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button
|
||||
><Button v-if="canWrite" size="sm" class="h-7 gap-1 text-xs" :disabled="operationBusy" @click="openCreate"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.create") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent v-for="tab in tabs" :key="tab.value" :value="tab.value" class="m-0 min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="capability !== 'supported'" class="border px-3 py-2 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capability) }) }}</div>
|
||||
<div v-else-if="loading" class="flex h-28 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<div v-else-if="error" class="border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ error }}</div>
|
||||
<div v-else class="divide-y border">
|
||||
<div v-for="item in items" :key="itemId(item)" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ itemLabel(item) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ itemId(item) }}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="operationBusy" :title="t('consul.ui.viewOrEdit')" @click="openEdit(item)"><component :is="canWrite ? Pencil : Eye" class="h-3.5 w-3.5" /></Button
|
||||
><Button v-if="canWrite && active === 'token'" variant="ghost" size="icon" class="h-7 w-7" :disabled="operationBusy" :title="t('consul.ui.cloneToken')" @click="cloneToken(item)"><Clipboard class="h-3.5 w-3.5" /></Button
|
||||
><Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" :disabled="operationBusy" :title="t('consul.ui.deleteAndInspectReferences')" @click="inspectDelete(item)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="!items.length" class="p-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noResources") }}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div v-if="secretOnce" class="m-3 flex items-center gap-2 border border-amber-300 bg-amber-50 p-3 text-sm dark:bg-amber-950/30">
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ secretRevealed ? secretOnce : "****************" }}</span
|
||||
><Button variant="ghost" size="icon" class="h-7 w-7" :title="secretRevealed ? t('consul.ui.hide') : t('consul.ui.reveal')" @click="secretRevealed = !secretRevealed"><EyeOff v-if="secretRevealed" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button
|
||||
><Button size="sm" class="h-7 gap-1" :disabled="operationBusy" @click="copySecret"><Clipboard class="h-3.5 w-3.5" />{{ t("consul.ui.copyOnce") }}</Button
|
||||
><Button variant="ghost" size="sm" @click="clearSecret">{{ t("consul.ui.hide") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog v-model:open="editorOpen"
|
||||
><DialogContent class="max-w-2xl"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ editorId ? t("consul.ui.editResource", { kind: activeLabel }) : t("consul.ui.createResource", { kind: activeLabel }) }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div v-if="editReferences" class="border bg-muted/20 px-3 py-2 text-xs">
|
||||
{{ t("consul.ui.knownImpact", { tokens: editReferences.tokenAccessorIds.length, roles: editReferences.roleIds.length, bindingRules: editReferences.bindingRuleIds.length }) }}<span v-if="!editReferences.complete" class="ml-2 text-destructive">{{ t("consul.ui.incomplete") }}</span>
|
||||
</div>
|
||||
<textarea v-model="editorJson" class="h-80 w-full resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none" spellcheck="false" :readonly="active === 'templatedPolicy'" /><DialogFooter
|
||||
><Button variant="outline" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button v-if="canWrite" @click="save">{{ t("consul.ui.save") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
<NacosConfigDiffDialog v-model:open="savePreviewOpen" :title="t('consul.ui.reviewAclChanges')" :before="editorOriginalJson" :after="editorJson" :confirm-label="t('consul.ui.apply')" @confirm="confirmSave" />
|
||||
<Dialog
|
||||
:open="Boolean(pendingDelete)"
|
||||
@update:open="
|
||||
(value) => {
|
||||
if (!value) closeDelete();
|
||||
}
|
||||
"
|
||||
><DialogContent
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("consul.ui.deleteResource", { label: pendingDelete?.label }) }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div v-if="references" class="space-y-2 text-sm">
|
||||
<p>{{ t("consul.ui.tokenReferences", { tokens: references.tokenAccessorIds.length, roles: references.roleIds.length, bindingRules: references.bindingRuleIds.length }) }}</p>
|
||||
<p v-if="!references.complete" class="text-destructive">{{ t("consul.ui.impactBlocked") }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="operationBusy" @click="closeDelete">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="operationBusy || !references?.complete" @click="remove">{{ t("consul.ui.delete") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,666 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { CircleCheck, CircleX, Loader2, Pause, Plus, Radio, RefreshCcw, Search, Trash2, TriangleAlert, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import ConsulListPagination from "@/components/consul/ConsulListPagination.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { nextWatchIndex } from "@/lib/consul/watchState";
|
||||
import { consulAgentWriteTargetSafe } from "@/lib/consul/agentTarget";
|
||||
import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/lib/consul/pagination";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulAgentCheckDefinition, ConsulAgentCheckRegistration, ConsulAgentIdentity, ConsulCheckStatus, ConsulDomainWatchResponse, ConsulHealthCheck, ConsulScope, ConsulServiceInstance } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const state = ref("any");
|
||||
const browseMode = ref<"state" | "node" | "service">("state");
|
||||
const browseName = ref("");
|
||||
const browseNameDraft = ref("");
|
||||
const keywordFilter = ref("");
|
||||
const statusFilter = ref("any");
|
||||
const typeFilter = ref("any");
|
||||
const checks = ref<ConsulHealthCheck[]>([]);
|
||||
const selectedCheck = ref<ConsulHealthCheck | null>(null);
|
||||
const serviceInstances = ref<ConsulServiceInstance[]>([]);
|
||||
const localChecks = ref<Record<string, ConsulHealthCheck>>({});
|
||||
const identity = ref<ConsulAgentIdentity | null>(null);
|
||||
const checkPage = ref(1);
|
||||
const localCheckPage = ref(1);
|
||||
const serviceInstancePage = ref(1);
|
||||
const agentTarget = computed(() => {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
const raw = config.agentTarget || config.agent_target;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const target = raw as Record<string, unknown>;
|
||||
const node = String(target.node || "").trim();
|
||||
const address = String(target.address || "").trim();
|
||||
return node && address ? { node, address } : null;
|
||||
});
|
||||
const canAgentWrite = computed(() => consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node));
|
||||
const errors = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const editorOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const form = ref({ id: "", name: "", serviceId: "", type: "ttl", endpoint: "", interval: "10s", timeout: "2s", ttl: "30s", args: "" });
|
||||
let sequence = 0;
|
||||
const watchEnabled = ref(false);
|
||||
const watchPaused = ref(false);
|
||||
const watchIndex = ref<string | null>(null);
|
||||
let watchConnectionId = "";
|
||||
let watchScope: ConsulScope = { datacenter: "", namespace: "", partition: "" };
|
||||
let watchGeneration = 0;
|
||||
let watchOperationId = "";
|
||||
|
||||
function normalizedCheckStatus(check: ConsulHealthCheck): string {
|
||||
return check.Maintenance ? "maintenance" : String(check.Status || "unknown").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function checkType(check: ConsulHealthCheck): string {
|
||||
if (check.Maintenance) return "maintenance";
|
||||
if (check.Definition?.TTL) return "ttl";
|
||||
if (check.Definition?.HTTP) return "http";
|
||||
if (check.Definition?.TCP) return "tcp";
|
||||
if (check.Definition?.GRPC) return "grpc";
|
||||
const raw = String(check.Type || "")
|
||||
.trim()
|
||||
.toLocaleLowerCase();
|
||||
if (raw) return raw;
|
||||
return check.ServiceID ? "service" : "node";
|
||||
}
|
||||
|
||||
function matchesHealthFilters(check: ConsulHealthCheck): boolean {
|
||||
if (statusFilter.value !== "any" && normalizedCheckStatus(check) !== statusFilter.value) return false;
|
||||
if (typeFilter.value !== "any" && checkType(check) !== typeFilter.value) return false;
|
||||
const query = keywordFilter.value.trim().toLocaleLowerCase();
|
||||
if (!query) return true;
|
||||
return [check.Name, check.CheckID, check.Node, check.ServiceName, check.ServiceID, check.Output, check.Notes, check.Type, check.ServiceTags.join(" "), JSON.stringify(check.Definition)].join(" ").toLocaleLowerCase().includes(query);
|
||||
}
|
||||
|
||||
const localCheckItems = computed(() => Object.values(localChecks.value).sort((left, right) => left.Name.localeCompare(right.Name) || left.CheckID.localeCompare(right.CheckID)));
|
||||
const filteredChecks = computed(() => checks.value.filter(matchesHealthFilters));
|
||||
const filteredLocalChecks = computed(() => localCheckItems.value.filter(matchesHealthFilters));
|
||||
const pagedChecks = computed(() => paginateConsulItems(filteredChecks.value, checkPage.value));
|
||||
const pagedLocalChecks = computed(() => paginateConsulItems(filteredLocalChecks.value, localCheckPage.value));
|
||||
const pagedServiceInstances = computed(() => paginateConsulItems(serviceInstances.value, serviceInstancePage.value));
|
||||
const availableCheckTypes = computed(() => [...new Set([...checks.value, ...localCheckItems.value].map(checkType))].sort());
|
||||
const healthSummary = computed(() => {
|
||||
const summary = { passing: 0, warning: 0, critical: 0, maintenance: 0 };
|
||||
for (const check of checks.value) {
|
||||
const status = normalizedCheckStatus(check);
|
||||
if (status in summary) summary[status as keyof typeof summary]++;
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
const hasActiveHealthFilters = computed(() => Boolean(keywordFilter.value.trim() || statusFilter.value !== "any" || typeFilter.value !== "any"));
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
function selectCheck(check: ConsulHealthCheck) {
|
||||
selectedCheck.value = selectedCheck.value?.CheckID === check.CheckID && selectedCheck.value?.Node === check.Node ? null : check;
|
||||
}
|
||||
function currentScope(): ConsulScope {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
return {
|
||||
datacenter: String(config.datacenter || config.consulDatacenter || config.consul_datacenter || ""),
|
||||
namespace: String(config.namespace || config.consulNamespace || config.consul_namespace || ""),
|
||||
partition: String(config.partition || config.consulPartition || config.consul_partition || ""),
|
||||
};
|
||||
}
|
||||
function newWatchId() {
|
||||
return globalThis.crypto?.randomUUID?.() || `consul-health-${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
function watchActive(operationId: string) {
|
||||
return watchEnabled.value && !watchPaused.value && watchOperationId === operationId && watchConnectionId === props.connectionId && watchGeneration === consulStore.generation;
|
||||
}
|
||||
async function stopWatch() {
|
||||
const operationId = watchOperationId;
|
||||
watchOperationId = "";
|
||||
if (!operationId) return;
|
||||
consulStore.completeOperation(operationId);
|
||||
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, operationId).catch(() => false);
|
||||
}
|
||||
function watchTarget(name: string) {
|
||||
if (browseMode.value === "node") return { kind: "healthNode" as const, node: name };
|
||||
if (browseMode.value === "service") return { kind: "healthServiceInstances" as const, service: name, passing: null };
|
||||
return { kind: "healthState" as const, state: name };
|
||||
}
|
||||
|
||||
async function runWatch(operationId: string, name: string, mode: typeof browseMode.value) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && browseMode.value === mode && (mode === "state" ? state.value : browseName.value) === name) {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: watchTarget(name),
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<ConsulHealthCheck[] | ConsulServiceInstance[]>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) {
|
||||
if (mode === "service") {
|
||||
serviceInstances.value = result.items as ConsulServiceInstance[];
|
||||
checks.value = serviceInstances.value.flatMap((instance) => instance.Checks);
|
||||
} else checks.value = result.items as ConsulHealthCheck[];
|
||||
selectedCheck.value = checks.value.find((check) => check.CheckID === selectedCheck.value?.CheckID && check.Node === selectedCheck.value?.Node) || null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
async function startWatch() {
|
||||
if (watchOperationId && watchEnabled.value && !watchPaused.value) return;
|
||||
watchEnabled.value = true;
|
||||
watchPaused.value = false;
|
||||
watchConnectionId = props.connectionId;
|
||||
watchScope = currentScope();
|
||||
consulStore.bindConnection(watchConnectionId, watchScope);
|
||||
watchGeneration = consulStore.generation;
|
||||
watchOperationId = newWatchId();
|
||||
consulStore.registerOperation(watchOperationId);
|
||||
const name = browseMode.value === "state" ? state.value : browseName.value;
|
||||
if (!name.trim()) {
|
||||
watchEnabled.value = false;
|
||||
consulStore.completeOperation(watchOperationId);
|
||||
watchOperationId = "";
|
||||
return;
|
||||
}
|
||||
void runWatch(watchOperationId, name, browseMode.value);
|
||||
}
|
||||
async function pauseWatch() {
|
||||
watchPaused.value = true;
|
||||
await stopWatch();
|
||||
}
|
||||
async function independent(name: string, action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
errors.value = { ...errors.value, [name]: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadHealthQuery() {
|
||||
const resume = watchEnabled.value && !watchPaused.value;
|
||||
await stopWatch();
|
||||
await load();
|
||||
if (resume && (browseMode.value === "state" || browseName.value.trim())) await startWatch();
|
||||
}
|
||||
|
||||
async function switchHealthBrowseMode(mode: typeof browseMode.value) {
|
||||
if (browseMode.value === mode) return;
|
||||
browseMode.value = mode;
|
||||
browseName.value = "";
|
||||
browseNameDraft.value = "";
|
||||
selectedCheck.value = null;
|
||||
await reloadHealthQuery();
|
||||
}
|
||||
|
||||
async function applyBrowseTarget() {
|
||||
const target = browseNameDraft.value.trim();
|
||||
if (!target) return;
|
||||
browseName.value = target;
|
||||
selectedCheck.value = null;
|
||||
await reloadHealthQuery();
|
||||
}
|
||||
|
||||
function clearHealthFilters() {
|
||||
keywordFilter.value = "";
|
||||
statusFilter.value = "any";
|
||||
typeFilter.value = "any";
|
||||
selectedCheck.value = null;
|
||||
}
|
||||
|
||||
function applyStatusFilter(status: string) {
|
||||
statusFilter.value = statusFilter.value === status ? "any" : status;
|
||||
}
|
||||
|
||||
function checkTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
ttl: t("consul.ui.ttl"),
|
||||
http: t("consul.ui.http"),
|
||||
tcp: t("consul.ui.tcp"),
|
||||
grpc: t("consul.ui.grpc"),
|
||||
docker: t("consul.ui.docker"),
|
||||
script: t("consul.ui.script"),
|
||||
maintenance: t("consul.ui.maintenance"),
|
||||
service: t("consul.ui.service"),
|
||||
node: t("consul.ui.node"),
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
await Promise.all([
|
||||
independent(t("consul.ui.healthChecks"), async () => {
|
||||
if (browseMode.value === "state") {
|
||||
const value = await api.consulHealthState(props.connectionId, state.value);
|
||||
if (current === sequence) {
|
||||
checks.value = value.items;
|
||||
serviceInstances.value = [];
|
||||
}
|
||||
} else if (browseMode.value === "node") {
|
||||
if (!browseName.value.trim()) {
|
||||
checks.value = [];
|
||||
return;
|
||||
}
|
||||
const value = await api.consulHealthNode(props.connectionId, browseName.value);
|
||||
if (current === sequence) {
|
||||
checks.value = value.items;
|
||||
serviceInstances.value = [];
|
||||
}
|
||||
} else {
|
||||
if (!browseName.value.trim()) {
|
||||
checks.value = [];
|
||||
serviceInstances.value = [];
|
||||
return;
|
||||
}
|
||||
const value = await api.consulHealthService(props.connectionId, browseName.value);
|
||||
if (current === sequence) {
|
||||
serviceInstances.value = value.items;
|
||||
checks.value = value.items.flatMap((instance) => instance.Checks);
|
||||
}
|
||||
}
|
||||
}),
|
||||
independent(t("consul.ui.agent"), async () => {
|
||||
const value = await api.consulAgentSelf(props.connectionId);
|
||||
if (current === sequence) identity.value = value;
|
||||
}),
|
||||
independent(t("consul.ui.localAgentChecks"), async () => {
|
||||
const value = await api.consulAgentChecks(props.connectionId);
|
||||
if (current === sequence) localChecks.value = value;
|
||||
}),
|
||||
]);
|
||||
if (current === sequence) {
|
||||
selectedCheck.value = checks.value.find((check) => check.CheckID === selectedCheck.value?.CheckID && check.Node === selectedCheck.value?.Node) || null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function definition(): ConsulAgentCheckDefinition {
|
||||
const common = { interval: form.value.interval, timeout: form.value.timeout };
|
||||
if (form.value.type === "http") return { type: "http", url: form.value.endpoint, method: "GET", ...common, tlsSkipVerify: false };
|
||||
if (form.value.type === "tcp") return { type: "tcp", address: form.value.endpoint, ...common };
|
||||
if (form.value.type === "grpc") return { type: "grpc", address: form.value.endpoint, ...common, tls: false };
|
||||
if (form.value.type === "docker") return { type: "docker", containerId: form.value.endpoint, shell: "", args: form.value.args.split(/\s+/).filter(Boolean), ...common };
|
||||
if (form.value.type === "script") return { type: "script", args: form.value.args.split(/\s+/).filter(Boolean), ...common };
|
||||
return { type: "ttl", ttl: form.value.ttl };
|
||||
}
|
||||
|
||||
async function registerCheck() {
|
||||
if (!canAgentWrite.value || !form.value.name.trim()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const registration: ConsulAgentCheckRegistration = { id: form.value.id.trim(), name: form.value.name.trim(), notes: "", serviceId: form.value.serviceId.trim(), status: "critical", definition: definition() };
|
||||
await api.consulAgentRegisterCheck(props.connectionId, registration);
|
||||
editorOpen.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
errors.value = { ...errors.value, [t("consul.ui.writeOperation")]: errorMessage(error) };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTtl(id: string, status: ConsulCheckStatus) {
|
||||
if (canAgentWrite.value)
|
||||
await independent(t("consul.ui.writeOperation"), async () => {
|
||||
await api.consulAgentUpdateTtl(props.connectionId, id, status);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function deregister(id: string) {
|
||||
if (canAgentWrite.value && window.confirm(t("consul.ui.deregisterCheck", { id, node: agentTarget.value?.node || t("consul.ui.targetAgent") })))
|
||||
await independent(t("consul.ui.writeOperation"), async () => {
|
||||
await api.consulAgentDeregisterCheck(props.connectionId, id);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
function statusClass(check: ConsulHealthCheck) {
|
||||
if (check.Maintenance) return "text-blue-700 dark:text-blue-300";
|
||||
if (check.Status === "passing") return "text-emerald-700 dark:text-emerald-300";
|
||||
if (check.Status === "warning") return "text-amber-700 dark:text-amber-300";
|
||||
return "text-destructive";
|
||||
}
|
||||
function statusLabel(check: ConsulHealthCheck) {
|
||||
if (check.Maintenance) return t("consul.ui.maintenance");
|
||||
if (check.Status === "passing") return t("consul.ui.passing");
|
||||
if (check.Status === "warning") return t("consul.ui.warning");
|
||||
if (check.Status === "critical") return t("consul.ui.critical");
|
||||
return t("consul.ui.unknown");
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
async () => {
|
||||
await stopWatch();
|
||||
watchEnabled.value = false;
|
||||
watchPaused.value = false;
|
||||
watchIndex.value = null;
|
||||
await load();
|
||||
},
|
||||
);
|
||||
watch(state, async () => {
|
||||
if (browseMode.value === "state") await reloadHealthQuery();
|
||||
});
|
||||
watch([keywordFilter, statusFilter, typeFilter], () => {
|
||||
checkPage.value = 1;
|
||||
localCheckPage.value = 1;
|
||||
selectedCheck.value = null;
|
||||
});
|
||||
watch(filteredChecks, (items) => {
|
||||
checkPage.value = clampConsulPage(checkPage.value, items.length);
|
||||
});
|
||||
watch(filteredLocalChecks, (items) => {
|
||||
localCheckPage.value = clampConsulPage(localCheckPage.value, items.length);
|
||||
});
|
||||
watch(serviceInstances, (items) => {
|
||||
serviceInstancePage.value = clampConsulPage(serviceInstancePage.value, items.length);
|
||||
});
|
||||
onMounted(load);
|
||||
onDeactivated(() => {
|
||||
watchPaused.value = true;
|
||||
void stopWatch();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
void stopWatch();
|
||||
});
|
||||
defineExpose({ refresh: () => (void load(), true) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto p-4">
|
||||
<header class="mb-4 flex flex-wrap items-start justify-between gap-3 border-b pb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold">{{ t("consul.ui.healthChecks") }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge :variant="canAgentWrite ? 'secondary' : 'outline'" :class="canAgentWrite ? 'text-emerald-700 dark:text-emerald-300' : 'border-amber-500/40 text-amber-700 dark:text-amber-300'">{{
|
||||
t("consul.ui.agentTarget", { target: canAgentWrite && agentTarget ? `${agentTarget.node} (${agentTarget.address})` : t("consul.ui.unknownWritesDisabled") })
|
||||
}}</Badge>
|
||||
<span v-if="watchEnabled">{{ watchPaused ? t("consul.ui.watchPaused") : t("consul.ui.watchingIndex", { index: watchIndex || "1" }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :title="watchEnabled && !watchPaused ? t('consul.ui.pauseWatch') : t('consul.ui.startWatch')" :disabled="browseMode !== 'state' && !browseName.trim()" @click="watchEnabled && !watchPaused ? pauseWatch() : startWatch()"
|
||||
><Pause v-if="watchEnabled && !watchPaused" class="h-3.5 w-3.5" /><Radio v-else class="h-3.5 w-3.5" />{{ watchEnabled && !watchPaused ? t("consul.ui.pauseWatch") : t("consul.ui.startWatch") }}</Button
|
||||
>
|
||||
<Button size="icon" variant="outline" class="h-8 w-8" :disabled="loading" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="!canAgentWrite" @click="editorOpen = !editorOpen"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.register") }}</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mb-4 rounded-lg border bg-muted/10 p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="grid shrink-0 grid-cols-3 text-xs">
|
||||
<Button size="sm" class="h-8 rounded-r-none" :variant="browseMode === 'state' ? 'default' : 'outline'" @click="switchHealthBrowseMode('state')">{{ t("consul.ui.state") }}</Button>
|
||||
<Button size="sm" class="h-8 rounded-none" :variant="browseMode === 'node' ? 'default' : 'outline'" @click="switchHealthBrowseMode('node')">{{ t("consul.ui.node") }}</Button>
|
||||
<Button size="sm" class="h-8 rounded-l-none" :variant="browseMode === 'service' ? 'default' : 'outline'" @click="switchHealthBrowseMode('service')">{{ t("consul.ui.service") }}</Button>
|
||||
</div>
|
||||
<select v-if="browseMode === 'state'" v-model="state" class="h-8 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allStates") }}</option>
|
||||
<option value="passing">{{ t("consul.ui.passing") }}</option>
|
||||
<option value="warning">{{ t("consul.ui.warning") }}</option>
|
||||
<option value="critical">{{ t("consul.ui.critical") }}</option>
|
||||
</select>
|
||||
<div v-else class="flex min-w-56 flex-1 sm:max-w-sm">
|
||||
<Input v-model="browseNameDraft" class="h-8 rounded-r-none text-xs" :placeholder="browseMode === 'node' ? t('consul.ui.nodeName') : t('consul.ui.serviceName')" @keydown.enter="applyBrowseTarget" />
|
||||
<Button size="sm" class="h-8 rounded-l-none px-3" :disabled="!browseNameDraft.trim()" @click="applyBrowseTarget">{{ t("consul.ui.query") }}</Button>
|
||||
</div>
|
||||
<div class="hidden h-6 w-px bg-border lg:block" />
|
||||
<div class="relative min-w-64 flex-1">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="keywordFilter" class="h-8 pl-8 text-xs" :placeholder="t('consul.ui.searchHealthChecks')" />
|
||||
</div>
|
||||
<select v-model="statusFilter" class="h-8 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allStates") }}</option>
|
||||
<option value="passing">{{ t("consul.ui.passing") }}</option>
|
||||
<option value="warning">{{ t("consul.ui.warning") }}</option>
|
||||
<option value="critical">{{ t("consul.ui.critical") }}</option>
|
||||
<option value="maintenance">{{ t("consul.ui.maintenance") }}</option>
|
||||
</select>
|
||||
<select v-model="typeFilter" class="h-8 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allCheckTypes") }}</option>
|
||||
<option v-for="type in availableCheckTypes" :key="type" :value="type">{{ checkTypeLabel(type) }}</option>
|
||||
</select>
|
||||
<Button v-if="hasActiveHealthFilters" size="sm" variant="ghost" class="h-8 gap-1 px-2 text-xs" @click="clearHealthFilters"><X class="h-3.5 w-3.5" />{{ t("consul.ui.clearFilters") }}</Button>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 border-t pt-3 text-xs">
|
||||
<span class="mr-1 text-muted-foreground">{{ t("consul.ui.healthResultCount", { filtered: filteredChecks.length, total: checks.length }) }}</span>
|
||||
<button type="button" @click="applyStatusFilter('passing')">
|
||||
<Badge variant="outline" :class="statusFilter === 'passing' ? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'text-emerald-700 dark:text-emerald-300'">{{ t("consul.ui.passing") }} {{ healthSummary.passing }}</Badge>
|
||||
</button>
|
||||
<button type="button" @click="applyStatusFilter('warning')">
|
||||
<Badge variant="outline" :class="statusFilter === 'warning' ? 'border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'text-amber-700 dark:text-amber-300'">{{ t("consul.ui.warning") }} {{ healthSummary.warning }}</Badge>
|
||||
</button>
|
||||
<button type="button" @click="applyStatusFilter('critical')">
|
||||
<Badge variant="outline" :class="statusFilter === 'critical' ? 'border-destructive/50 bg-destructive/10 text-destructive' : 'text-destructive'">{{ t("consul.ui.critical") }} {{ healthSummary.critical }}</Badge>
|
||||
</button>
|
||||
<button type="button" @click="applyStatusFilter('maintenance')">
|
||||
<Badge variant="outline" :class="statusFilter === 'maintenance' ? 'border-blue-500/50 bg-blue-500/10 text-blue-700 dark:text-blue-300' : ''">{{ t("consul.ui.maintenance") }} {{ healthSummary.maintenance }}</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="Object.keys(errors).length" class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div v-for="(error, name) in errors" :key="name">
|
||||
<strong>{{ name }}:</strong> {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="editorOpen" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="border-b bg-muted/30 px-4 py-3">
|
||||
<h3 class="text-sm font-medium">{{ t("consul.ui.register") }}</h3>
|
||||
<p class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ t("consul.ui.checkRegistrationHint", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }}</p>
|
||||
</div>
|
||||
<div class="grid gap-x-5 gap-y-4 p-4 md:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.name") }}</Label
|
||||
><Input v-model="form.name" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.id") }}</Label
|
||||
><Input v-model="form.id" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.serviceId") }}</Label
|
||||
><Input v-model="form.serviceId" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.type") }}</Label
|
||||
><select v-model="form.type" class="h-9 w-full rounded-md border bg-background px-2 text-sm">
|
||||
<option value="ttl">{{ t("consul.ui.ttl") }}</option>
|
||||
<option value="http">{{ t("consul.ui.http") }}</option>
|
||||
<option value="tcp">{{ t("consul.ui.tcp") }}</option>
|
||||
<option value="grpc">{{ t("consul.ui.grpc") }}</option>
|
||||
<option value="docker">{{ t("consul.ui.docker") }}</option>
|
||||
<option value="script">{{ t("consul.ui.script") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.type === 'ttl'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.ttl") }}</Label
|
||||
><Input v-model="form.ttl" class="h-9" />
|
||||
</div>
|
||||
<div v-else-if="form.type === 'script'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.arguments") }}</Label
|
||||
><Input v-model="form.args" class="h-9" />
|
||||
</div>
|
||||
<div v-else class="space-y-1.5">
|
||||
<Label class="text-xs">{{ form.type === "docker" ? t("consul.ui.containerId") : t("consul.ui.endpoint") }}</Label
|
||||
><Input v-model="form.endpoint" class="h-9" />
|
||||
</div>
|
||||
<template v-if="form.type !== 'ttl'">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.interval") }}</Label
|
||||
><Input v-model="form.interval" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.timeout") }}</Label
|
||||
><Input v-model="form.timeout" class="h-9" />
|
||||
</div>
|
||||
<div v-if="form.type === 'docker'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.commandArguments") }}</Label
|
||||
><Input v-model="form.args" class="h-9" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 border-t bg-muted/10 px-4 py-3">
|
||||
<Button size="sm" variant="outline" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button size="sm" :disabled="saving || !canAgentWrite || !form.name.trim()" @click="registerCheck"><Loader2 v-if="saving" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("consul.ui.registerOn", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ browseMode === "state" ? t("consul.ui.clusterHealth") : browseMode === "node" ? t("consul.ui.nodeHealth", { node: browseName || "-" }) : t("consul.ui.serviceHealth", { service: browseName || "-" }) }}</span
|
||||
><Badge variant="secondary">{{ filteredChecks.length }}</Badge>
|
||||
</div>
|
||||
<ConsulListPagination
|
||||
:total="filteredChecks.length"
|
||||
:page="checkPage"
|
||||
:page-size="CONSUL_LIST_PAGE_SIZE"
|
||||
@update:page="
|
||||
checkPage = $event;
|
||||
selectedCheck = null;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-for="check in pagedChecks"
|
||||
:key="`${check.Node}-${check.CheckID}`"
|
||||
type="button"
|
||||
class="grid min-h-16 w-full grid-cols-[2rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2 border-b px-4 py-3 text-left text-xs last:border-0 hover:bg-muted/30 focus-visible:bg-muted/30 focus-visible:outline-none"
|
||||
:class="selectedCheck?.CheckID === check.CheckID && selectedCheck?.Node === check.Node && 'bg-muted/40'"
|
||||
:aria-expanded="selectedCheck?.CheckID === check.CheckID && selectedCheck?.Node === check.Node"
|
||||
@click="selectCheck(check)"
|
||||
>
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60"
|
||||
><CircleCheck v-if="check.Status === 'passing' && !check.Maintenance" class="h-4 w-4 text-emerald-600" /><TriangleAlert v-else-if="check.Status === 'warning' || check.Maintenance" class="h-4 w-4 text-amber-600" /><CircleX v-else class="h-4 w-4 text-destructive"
|
||||
/></span>
|
||||
<span class="grid min-w-0 gap-x-8 gap-y-2 md:grid-cols-[minmax(12rem,1.1fr)_minmax(10rem,.75fr)] xl:grid-cols-[minmax(14rem,1fr)_minmax(10rem,.65fr)_minmax(14rem,1.15fr)]">
|
||||
<span class="min-w-0">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-2"
|
||||
><span class="truncate font-medium">{{ check.Name }}</span
|
||||
><Badge variant="outline" class="h-5 px-1.5 text-[10px]" :class="statusClass(check)">{{ statusLabel(check) }}</Badge
|
||||
><Badge variant="secondary" class="h-5 px-1.5 text-[10px]">{{ checkTypeLabel(checkType(check)) }}</Badge></span
|
||||
>
|
||||
<span class="mt-1 block truncate font-mono text-[11px] text-muted-foreground" :title="check.CheckID">{{ check.CheckID }}</span>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-[10px] uppercase tracking-wide text-muted-foreground">{{ check.ServiceID ? t("consul.ui.service") : t("consul.ui.node") }}</span>
|
||||
<span class="mt-1 block truncate font-medium" :title="check.ServiceName || check.Node">{{ check.ServiceName || check.Node }}</span>
|
||||
<span v-if="check.ServiceID" class="mt-0.5 block truncate text-muted-foreground" :title="check.Node">{{ check.Node }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 md:col-span-2 xl:col-span-1">
|
||||
<span class="block text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("consul.ui.output") }}</span>
|
||||
<span class="mt-1 block truncate text-muted-foreground" :title="check.Output || check.Notes">{{ check.Output || check.Notes || "-" }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!filteredChecks.length && !loading" class="px-3 py-8 text-center text-xs text-muted-foreground">{{ t("consul.ui.noMatchingHealthChecks") }}</div>
|
||||
<div v-if="loading" class="flex items-center justify-center px-3 py-8 text-xs text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
</section>
|
||||
<section v-if="selectedCheck" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.checkDetails", { check: selectedCheck.Name }) }}</span
|
||||
><Button size="icon" variant="ghost" class="h-7 w-7" :title="t('consul.ui.close')" @click="selectedCheck = null"><CircleX class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<dl class="grid text-xs md:grid-cols-2">
|
||||
<div class="border-b px-3 py-3 md:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.checkId") }}</dt>
|
||||
<dd class="mt-1 break-all font-mono">{{ selectedCheck.CheckID }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.serviceId") }}</dt>
|
||||
<dd class="mt-1 break-all font-mono">{{ selectedCheck.ServiceID || "-" }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3 md:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.notes") }}</dt>
|
||||
<dd class="mt-1 whitespace-pre-wrap break-words">{{ selectedCheck.Notes || "-" }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.output") }}</dt>
|
||||
<dd class="mt-1 whitespace-pre-wrap break-words">{{ selectedCheck.Output || "-" }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3 md:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.definition") }}</dt>
|
||||
<dd class="mt-1 break-all font-mono">{{ JSON.stringify(selectedCheck.Definition) }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.indexes") }}</dt>
|
||||
<dd class="mt-1 font-mono">{{ selectedCheck.CreateIndex }} / {{ selectedCheck.ModifyIndex }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section v-if="browseMode === 'service' && serviceInstances.length" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.serviceInstances") }}</span
|
||||
><Badge variant="secondary">{{ serviceInstances.length }}</Badge>
|
||||
</div>
|
||||
<ConsulListPagination :total="serviceInstances.length" :page="serviceInstancePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="serviceInstancePage = $event" />
|
||||
</div>
|
||||
<div v-for="instance in pagedServiceInstances" :key="`${instance.Node.Node}-${instance.Service.ID}`" class="border-b px-3 py-2.5 text-xs last:border-0">
|
||||
<span class="font-medium">{{ instance.Node.Node }}</span
|
||||
><span class="ml-2 text-muted-foreground">{{ instance.Service.Address || instance.Node.Address }}:{{ instance.Service.Port }} · {{ instance.Service.ID }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between gap-2 border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.localAgentChecks") }}</span
|
||||
><Badge variant="secondary"
|
||||
>{{ filteredLocalChecks.length }}<span v-if="hasActiveHealthFilters"> / {{ localCheckItems.length }}</span></Badge
|
||||
>
|
||||
</div>
|
||||
<ConsulListPagination :total="filteredLocalChecks.length" :page="localCheckPage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="localCheckPage = $event" />
|
||||
</div>
|
||||
<div v-for="check in pagedLocalChecks" :key="check.CheckID" class="grid min-h-16 gap-3 border-b px-4 py-3 text-xs last:border-0 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div class="grid min-w-0 grid-cols-[2rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2 sm:grid-cols-[2rem_minmax(12rem,1fr)_minmax(10rem,.7fr)] xl:grid-cols-[2rem_minmax(13rem,.9fr)_minmax(10rem,.65fr)_minmax(13rem,1fr)]">
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-full bg-muted/60"
|
||||
><CircleCheck v-if="check.Status === 'passing' && !check.Maintenance" class="h-4 w-4 text-emerald-600" /><TriangleAlert v-else-if="check.Status === 'warning' || check.Maintenance" class="h-4 w-4 text-amber-600" /><CircleX v-else class="h-4 w-4 text-destructive"
|
||||
/></span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate font-medium">{{ check.Name }}</span
|
||||
><Badge variant="outline" class="h-5 px-1.5 text-[10px]" :class="statusClass(check)">{{ statusLabel(check) }}</Badge
|
||||
><Badge variant="secondary" class="h-5 px-1.5 text-[10px]">{{ checkTypeLabel(checkType(check)) }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 truncate font-mono text-[11px] text-muted-foreground" :title="check.CheckID">{{ check.CheckID }}</div>
|
||||
</div>
|
||||
<div class="col-start-2 min-w-0 sm:col-start-auto">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ check.ServiceID ? t("consul.ui.service") : t("consul.ui.node") }}</div>
|
||||
<div class="mt-1 truncate font-medium" :title="check.ServiceName || check.Node">{{ check.ServiceName || check.Node }}</div>
|
||||
<div v-if="check.ServiceID" class="mt-0.5 truncate text-muted-foreground" :title="check.Node">{{ check.Node }}</div>
|
||||
</div>
|
||||
<div class="col-start-2 min-w-0 sm:col-span-2 xl:col-span-1 xl:col-start-auto">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("consul.ui.output") }}</div>
|
||||
<div class="mt-1 truncate text-muted-foreground" :title="check.Output || check.Notes">{{ check.Output || check.Notes || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-0.5 border-t pt-2 lg:border-l lg:border-t-0 lg:pl-3 lg:pt-0">
|
||||
<template v-if="check.Definition?.TTL"
|
||||
><Button size="icon" variant="ghost" class="h-7 w-7 text-emerald-600" :disabled="!canAgentWrite" :title="t('consul.ui.ttlPass')" @click="updateTtl(check.CheckID, 'passing')"><CircleCheck class="h-3.5 w-3.5" /></Button
|
||||
><Button size="icon" variant="ghost" class="h-7 w-7 text-amber-600" :disabled="!canAgentWrite" :title="t('consul.ui.ttlWarn')" @click="updateTtl(check.CheckID, 'warning')"><TriangleAlert class="h-3.5 w-3.5" /></Button
|
||||
><Button size="icon" variant="ghost" class="h-7 w-7 text-destructive" :disabled="!canAgentWrite" :title="t('consul.ui.ttlFail')" @click="updateTtl(check.CheckID, 'critical')"><CircleX class="h-3.5 w-3.5" /></Button
|
||||
></template>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7 text-destructive" :disabled="!canAgentWrite" :title="t('consul.ui.deregister')" @click="deregister(check.CheckID)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filteredLocalChecks.length && !loading" class="px-3 py-8 text-center text-xs text-muted-foreground">{{ t("consul.ui.noMatchingHealthChecks") }}</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ChevronLeft, ChevronRight } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { clampConsulPage, consulPageCount } from "@/lib/consul/pagination";
|
||||
|
||||
const props = withDefaults(defineProps<{ total: number; page: number; pageSize: number; compact?: boolean }>(), {
|
||||
compact: false,
|
||||
});
|
||||
const emit = defineEmits<{ "update:page": [page: number] }>();
|
||||
const { t } = useI18n();
|
||||
const pages = computed(() => consulPageCount(props.total, props.pageSize));
|
||||
const current = computed(() => clampConsulPage(props.page, props.total, props.pageSize));
|
||||
const status = computed(() => t("consul.ui.pageStatus", { page: current.value, pages: pages.value, total: props.total }));
|
||||
|
||||
function move(delta: number) {
|
||||
emit("update:page", clampConsulPage(current.value + delta, props.total, props.pageSize));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="pages > 1" class="flex shrink-0 items-center justify-center gap-1 text-xs text-muted-foreground" :title="status">
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7" :disabled="current <= 1" :title="t('consul.ui.previousPage')" @click="move(-1)">
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span class="min-w-12 whitespace-nowrap text-center tabular-nums">{{ compact ? `${current} / ${pages}` : status }}</span>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7" :disabled="current >= pages" :title="t('consul.ui.nextPage')" @click="move(1)">
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { Clipboard, Eye, EyeOff, Loader2, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { ConsulCapabilities, ConsulConfigEntry, ConsulDiscoveryChain, ConsulExportedService, ConsulIntention, ConsulPeering } from "@/types/consul";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
type MeshTab = "config" | "intentions" | "discovery" | "peering" | "exported";
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const { t } = useI18n();
|
||||
function capabilityLabel(value?: string) {
|
||||
if (value === "supported") return t("consul.ui.capabilitySupported");
|
||||
if (value === "unsupported") return t("consul.ui.capabilityUnsupported");
|
||||
if (value === "disabled") return t("consul.ui.capabilityDisabled");
|
||||
if (value === "forbidden") return t("consul.ui.capabilityForbidden");
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const active = ref<MeshTab>("config");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const configKind = ref("service-defaults");
|
||||
const configs = ref<ConsulConfigEntry[]>([]);
|
||||
const intentions = ref<ConsulIntention[]>([]);
|
||||
const peerings = ref<ConsulPeering[]>([]);
|
||||
const exported = ref<ConsulExportedService[]>([]);
|
||||
const discoveryService = ref("");
|
||||
const chain = ref<ConsulDiscoveryChain | null>(null);
|
||||
const editorOpen = ref(false);
|
||||
const editorMode = ref<"config" | "intention" | "exported">("config");
|
||||
const editorJson = ref("{}");
|
||||
const editingConfig = ref<ConsulConfigEntry | null>(null);
|
||||
const peeringName = ref("");
|
||||
const peeringToken = ref("");
|
||||
const tokenOnce = ref("");
|
||||
const editingExported = ref<ConsulConfigEntry | null>(null);
|
||||
const tokenRevealed = ref(false);
|
||||
const editorOriginalJson = ref("");
|
||||
const savePreviewOpen = ref(false);
|
||||
const pendingEditorRaw = ref<Record<string, unknown> | null>(null);
|
||||
const intentionSource = ref("");
|
||||
const intentionDestination = ref("");
|
||||
const intentionCheckResult = ref<boolean | null>(null);
|
||||
const intentionMatches = ref<ConsulIntention[]>([]);
|
||||
let loadSequence = 0;
|
||||
const capability = computed(() =>
|
||||
active.value === "config" ? props.capabilities?.configEntries : active.value === "intentions" ? props.capabilities?.intentions : active.value === "peering" ? props.capabilities?.peering : active.value === "exported" ? props.capabilities?.exportedServices : "supported",
|
||||
);
|
||||
const canWrite = computed(() => !readOnly.value && capability.value === "supported");
|
||||
const chainRows = computed(() => {
|
||||
if (!chain.value?.Nodes) return [];
|
||||
const nodes = chain.value.Nodes;
|
||||
const rows: Array<{ id: string; depth: number; type: string; next: string[] }> = [];
|
||||
const seen = new Set<string>();
|
||||
const queue: Array<{ id: string; depth: number }> = [{ id: chain.value.StartNode || Object.keys(nodes)[0] || "", depth: 0 }];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
if (!current.id || seen.has(current.id)) continue;
|
||||
seen.add(current.id);
|
||||
const value = nodes[current.id];
|
||||
const next = collectNextNodes(value);
|
||||
rows.push({ id: current.id, depth: current.depth, type: nodeType(value), next });
|
||||
for (const id of next) if (nodes[id] && !seen.has(id)) queue.push({ id, depth: current.depth + 1 });
|
||||
}
|
||||
for (const id of Object.keys(nodes)) if (!seen.has(id)) rows.push({ id, depth: 0, type: nodeType(nodes[id]), next: collectNextNodes(nodes[id]) });
|
||||
return rows;
|
||||
});
|
||||
function nodeType(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "node";
|
||||
return String((value as Record<string, unknown>).Type || (value as Record<string, unknown>).type || "node");
|
||||
}
|
||||
function collectNextNodes(value: unknown): string[] {
|
||||
const found = new Set<string>();
|
||||
function visit(item: unknown) {
|
||||
if (Array.isArray(item)) {
|
||||
item.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (!item || typeof item !== "object") return;
|
||||
for (const [key, child] of Object.entries(item as Record<string, unknown>)) {
|
||||
if (key.toLowerCase() === "nextnode" && typeof child === "string" && child) found.add(child);
|
||||
else visit(child);
|
||||
}
|
||||
}
|
||||
visit(value);
|
||||
return [...found];
|
||||
}
|
||||
async function load() {
|
||||
const sequence = ++loadSequence;
|
||||
const connectionId = props.connectionId;
|
||||
const tab = active.value;
|
||||
const kind = configKind.value;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (tab === "config") {
|
||||
const value = await api.consulMeshConfigList(connectionId, kind);
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) configs.value = value;
|
||||
} else if (tab === "intentions") {
|
||||
const value = await api.consulMeshIntentionsList(connectionId);
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) intentions.value = value;
|
||||
} else if (tab === "peering") {
|
||||
const value = await api.consulMeshPeeringList(connectionId);
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) peerings.value = value;
|
||||
} else if (tab === "exported") {
|
||||
const value = await api.consulMeshExportedServicesList(connectionId);
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) exported.value = value;
|
||||
}
|
||||
} catch (cause) {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) loading.value = false;
|
||||
}
|
||||
}
|
||||
function openConfig(item?: ConsulConfigEntry) {
|
||||
editorMode.value = "config";
|
||||
editingConfig.value = item || null;
|
||||
editorOriginalJson.value = item ? JSON.stringify(item.raw, null, 2) : "";
|
||||
editorJson.value = JSON.stringify(item?.raw || { Kind: configKind.value, Name: "" }, null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
async function openIntention(item?: ConsulIntention) {
|
||||
let value = item;
|
||||
if (item && !item.ID && item.SourceName && item.DestinationName) value = await api.consulMeshIntentionGetExact(props.connectionId, { source: item.SourceName, destination: item.DestinationName });
|
||||
editorMode.value = "intention";
|
||||
editorOriginalJson.value = value ? JSON.stringify(value, null, 2) : "";
|
||||
editorJson.value = JSON.stringify(value || { SourceName: "", DestinationName: "", Action: "allow", Permissions: [] }, null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
async function openExported() {
|
||||
error.value = "";
|
||||
try {
|
||||
const entries = await api.consulMeshConfigList(props.connectionId, "exported-services");
|
||||
editingExported.value = entries[0] || null;
|
||||
const raw = editingExported.value?.raw || { Kind: "exported-services", Name: "default", Services: [] };
|
||||
editorMode.value = "exported";
|
||||
editorOriginalJson.value = editingExported.value ? JSON.stringify(raw, null, 2) : "";
|
||||
editorJson.value = JSON.stringify(raw, null, 2);
|
||||
editorOpen.value = true;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
try {
|
||||
pendingEditorRaw.value = JSON.parse(editorJson.value) as Record<string, unknown>;
|
||||
savePreviewOpen.value = true;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function confirmSave() {
|
||||
const raw = pendingEditorRaw.value;
|
||||
if (!raw) return;
|
||||
try {
|
||||
if (editorMode.value === "config") {
|
||||
const kind = String(raw.Kind || configKind.value),
|
||||
name = String(raw.Name || "");
|
||||
await api.consulMeshConfigApply(props.connectionId, { kind, name, expectedModifyIndex: editingConfig.value?.modifyIndex || 0, raw });
|
||||
} else if (editorMode.value === "intention") await api.consulMeshIntentionUpsert(props.connectionId, raw as ConsulIntention);
|
||||
else await api.consulMeshExportedServicesApply(props.connectionId, String(raw.Name || "default"), editingExported.value?.modifyIndex || 0, raw);
|
||||
savePreviewOpen.value = false;
|
||||
editorOpen.value = false;
|
||||
pendingEditorRaw.value = null;
|
||||
editingExported.value = null;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function removeConfig(item: ConsulConfigEntry) {
|
||||
if (!confirm(t("consul.ui.deleteConfig", { kind: item.kind, name: item.name }))) return;
|
||||
await api.consulMeshConfigDelete(props.connectionId, item.kind, item.name, item.modifyIndex);
|
||||
await load();
|
||||
}
|
||||
async function removeIntention(item: ConsulIntention) {
|
||||
if (item.ID) {
|
||||
if (!confirm(t("consul.ui.deleteIntention", { id: item.ID }))) return;
|
||||
await api.consulMeshIntentionDelete(props.connectionId, item.ID);
|
||||
} else if (item.SourceName && item.DestinationName) {
|
||||
if (!confirm(t("consul.ui.deleteIntentionExact", { source: item.SourceName, destination: item.DestinationName }))) return;
|
||||
await api.consulMeshIntentionDeleteExact(props.connectionId, { source: item.SourceName, destination: item.DestinationName });
|
||||
} else return;
|
||||
await load();
|
||||
}
|
||||
function permissionsSummary(item: ConsulIntention) {
|
||||
return item.Permissions?.length ? t("consul.ui.permissions", { count: item.Permissions.length }) : t("consul.ui.actionRule");
|
||||
}
|
||||
function intentionActionLabel(action?: string) {
|
||||
return action === "allow" ? t("consul.ui.allowed") : action === "deny" ? t("consul.ui.denied") : action || t("consul.ui.unknown");
|
||||
}
|
||||
function peeringStateLabel(state?: string) {
|
||||
const value = String(state || "").toLowerCase();
|
||||
if (value === "pending") return t("consul.ui.peeringPending");
|
||||
if (value === "establishing") return t("consul.ui.peeringEstablishing");
|
||||
if (value === "active") return t("consul.ui.peeringActive");
|
||||
if (value === "failing") return t("consul.ui.peeringFailing");
|
||||
if (value === "deleting") return t("consul.ui.peeringDeleting");
|
||||
if (value === "terminated") return t("consul.ui.peeringTerminated");
|
||||
return state || t("consul.ui.unknown");
|
||||
}
|
||||
async function checkIntention() {
|
||||
if (!intentionSource.value.trim() || !intentionDestination.value.trim()) return;
|
||||
try {
|
||||
const result = await api.consulMeshIntentionCheck(props.connectionId, { source: intentionSource.value.trim(), destination: intentionDestination.value.trim() });
|
||||
intentionCheckResult.value = result.Allowed;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function matchIntentions(by: "source" | "destination") {
|
||||
const name = (by === "source" ? intentionSource.value : intentionDestination.value).trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
intentionMatches.value = await api.consulMeshIntentionMatch(props.connectionId, { by, name });
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function loadChain() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
chain.value = await api.consulMeshDiscoveryChain(props.connectionId, discoveryService.value);
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function clearToken() {
|
||||
tokenOnce.value = "";
|
||||
tokenRevealed.value = false;
|
||||
}
|
||||
async function generateToken() {
|
||||
try {
|
||||
const result = await api.consulMeshPeeringGenerateToken(props.connectionId, { PeerName: peeringName.value });
|
||||
tokenOnce.value = result.PeeringToken;
|
||||
tokenRevealed.value = false;
|
||||
peeringName.value = "";
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function establishPeering() {
|
||||
try {
|
||||
await api.consulMeshPeeringEstablish(props.connectionId, { PeerName: peeringName.value, PeeringToken: peeringToken.value });
|
||||
peeringToken.value = "";
|
||||
peeringName.value = "";
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function copyToken() {
|
||||
await navigator.clipboard.writeText(tokenOnce.value);
|
||||
clearToken();
|
||||
}
|
||||
async function removePeering(name: string) {
|
||||
if (!confirm(t("consul.ui.deletePeering", { name }))) return;
|
||||
await api.consulMeshPeeringDelete(props.connectionId, name);
|
||||
await load();
|
||||
}
|
||||
watch([active, () => props.connectionId], () => {
|
||||
clearToken();
|
||||
void load();
|
||||
});
|
||||
onMounted(() => void load());
|
||||
onDeactivated(clearToken);
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
clearToken();
|
||||
});
|
||||
defineExpose({
|
||||
refresh: () => {
|
||||
void load();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Tabs v-model="active" class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex h-11 items-center justify-between border-b px-3">
|
||||
<TabsList class="h-8 p-0.5"
|
||||
><TabsTrigger value="config" class="h-7 px-2 text-xs">{{ t("consul.ui.configEntries") }}</TabsTrigger
|
||||
><TabsTrigger value="intentions" class="h-7 px-2 text-xs">{{ t("consul.ui.intentions") }}</TabsTrigger
|
||||
><TabsTrigger value="discovery" class="h-7 px-2 text-xs">{{ t("consul.ui.discoveryChain") }}</TabsTrigger
|
||||
><TabsTrigger value="peering" class="h-7 px-2 text-xs">{{ t("consul.ui.peering") }}</TabsTrigger
|
||||
><TabsTrigger value="exported" class="h-7 px-2 text-xs">{{ t("consul.ui.exportedServices") }}</TabsTrigger></TabsList
|
||||
><Button variant="ghost" size="icon" class="h-7 w-7" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="capability !== 'supported'" class="m-4 border p-3 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capability) }) }}</div>
|
||||
<div v-else-if="error" class="m-4 border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ error }}</div>
|
||||
<div v-if="loading" class="flex h-24 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<TabsContent value="config" class="m-0 min-h-0 flex-1 overflow-auto p-4"
|
||||
><div class="mb-3 flex gap-2">
|
||||
<Input v-model="configKind" class="max-w-xs" :placeholder="t('consul.ui.configEntryKind')" /><Button variant="outline" size="sm" @click="load">{{ t("consul.ui.loadKind") }}</Button
|
||||
><Button v-if="canWrite" size="sm" class="gap-1" @click="openConfig()"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.newJson") }}</Button>
|
||||
</div>
|
||||
<div class="divide-y border">
|
||||
<div v-for="item in configs" :key="`${item.kind}/${item.name}`" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ item.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ item.kind }} · {{ t("consul.ui.modifyIndex", { index: item.modifyIndex }) }}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="openConfig(item)">{{ t("consul.ui.rawJson") }}</Button
|
||||
><Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="removeConfig(item)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div></div
|
||||
></TabsContent>
|
||||
<TabsContent value="intentions" class="m-0 min-h-0 flex-1 overflow-auto p-4">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Button v-if="canWrite" size="sm" class="gap-1" @click="openIntention()"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.newIntention") }}</Button>
|
||||
<Input v-model="intentionSource" class="max-w-48" :placeholder="t('consul.ui.sourceService')" />
|
||||
<Input v-model="intentionDestination" class="max-w-48" :placeholder="t('consul.ui.destinationService')" />
|
||||
<Button variant="outline" size="sm" @click="checkIntention">{{ t("consul.ui.check") }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="matchIntentions('source')">{{ t("consul.ui.matchSource") }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="matchIntentions('destination')">{{ t("consul.ui.matchDestination") }}</Button>
|
||||
<span v-if="intentionCheckResult !== null" class="text-xs font-medium" :class="intentionCheckResult ? 'text-emerald-600' : 'text-destructive'">{{ intentionCheckResult ? t("consul.ui.allowed") : t("consul.ui.denied") }}</span>
|
||||
</div>
|
||||
<div v-if="intentionMatches.length" class="mb-3 border bg-muted/20 p-2 text-xs">{{ t("consul.ui.matchedIntentions", { count: intentionMatches.length }) }}</div>
|
||||
<div class="divide-y border">
|
||||
<div v-for="item in intentionMatches.length ? intentionMatches : intentions" :key="item.ID || `${item.SourceName}:${item.DestinationName}`" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ item.SourceName }} → {{ item.DestinationName }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ intentionActionLabel(item.Action) }} · {{ permissionsSummary(item) }} · {{ item.SourcePartition || t("consul.ui.default") }}/{{ item.SourceNamespace || t("consul.ui.default") }} → {{ item.DestinationPartition || t("consul.ui.default") }}/{{
|
||||
item.DestinationNamespace || t("consul.ui.default")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="openIntention(item)">{{ t("consul.ui.details") }}</Button
|
||||
><Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="removeIntention(item)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="discovery" class="m-0 min-h-0 flex-1 overflow-auto p-4">
|
||||
<div class="flex max-w-xl gap-2">
|
||||
<Input v-model="discoveryService" :placeholder="t('consul.ui.serviceName')" @keyup.enter="loadChain" /><Button @click="loadChain">{{ t("consul.ui.compile") }}</Button>
|
||||
</div>
|
||||
<div v-if="chain" class="mt-4 space-y-3">
|
||||
<div class="border bg-muted/20 px-3 py-2 text-xs">{{ chain.ServiceName }} · {{ chain.Protocol || t("consul.ui.defaultProtocol") }} · {{ chain.Datacenter || t("consul.ui.configuredDatacenter") }}</div>
|
||||
<div class="divide-y border">
|
||||
<div v-for="row in chainRows" :key="row.id" class="grid min-h-12 grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)] items-center gap-3 px-3 py-2 text-xs" :style="{ paddingLeft: `${12 + row.depth * 20}px` }">
|
||||
<div class="min-w-0 truncate font-mono">
|
||||
<span v-if="row.id === chain.StartNode" class="mr-1 text-emerald-600">{{ t("consul.ui.start") }}</span
|
||||
>{{ row.id }}
|
||||
</div>
|
||||
<Badge variant="outline" class="w-fit">{{ row.type }}</Badge>
|
||||
<div class="truncate text-muted-foreground">{{ row.next.length ? t("consul.ui.next", { nodes: row.next.join(", ") }) : t("consul.ui.terminalTarget") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<details v-if="chain.Targets && Object.keys(chain.Targets).length" class="border">
|
||||
<summary class="cursor-pointer px-3 py-2 text-xs font-medium">{{ t("consul.ui.upstreamTargets", { count: Object.keys(chain.Targets).length }) }}</summary>
|
||||
<pre class="max-h-64 overflow-auto border-t bg-muted/20 p-3 text-xs">{{ JSON.stringify(chain.Targets, null, 2) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="peering" class="m-0 min-h-0 flex-1 overflow-auto p-4"
|
||||
><div v-if="canWrite" class="mb-3 grid max-w-2xl grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_auto_auto] gap-2">
|
||||
<Input v-model="peeringName" :placeholder="t('consul.ui.peerName')" /><PasswordInput v-model="peeringToken" :placeholder="t('consul.ui.peeringToken')" /><Button :disabled="!peeringName" variant="outline" @click="generateToken">{{ t("consul.ui.generateToken") }}</Button
|
||||
><Button :disabled="!peeringName || !peeringToken" @click="establishPeering">{{ t("consul.ui.establish") }}</Button>
|
||||
</div>
|
||||
<div v-if="tokenOnce" class="mb-3 flex items-center gap-2 border border-amber-300 bg-amber-50 p-3 text-sm dark:bg-amber-950/30">
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ tokenRevealed ? tokenOnce : "****************" }}</span
|
||||
><Button variant="ghost" size="icon" class="h-7 w-7" :title="tokenRevealed ? t('consul.ui.hide') : t('consul.ui.reveal')" @click="tokenRevealed = !tokenRevealed"><EyeOff v-if="tokenRevealed" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button
|
||||
><Button size="sm" class="gap-1" @click="copyToken"><Clipboard class="h-3.5 w-3.5" />{{ t("consul.ui.copyOnce") }}</Button
|
||||
><Button variant="ghost" size="sm" @click="clearToken">{{ t("consul.ui.hide") }}</Button>
|
||||
</div>
|
||||
<div class="divide-y border">
|
||||
<div v-for="item in peerings" :key="item.Name" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{{ item.Name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ peeringStateLabel(item.State) }} · {{ item.Partition || t("consul.ui.default") }}</div>
|
||||
</div>
|
||||
<Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="removePeering(String(item.Name))"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div></div
|
||||
></TabsContent>
|
||||
<TabsContent value="exported" class="m-0 min-h-0 flex-1 overflow-auto p-4"
|
||||
><Button v-if="canWrite" size="sm" class="mb-3 gap-1" @click="openExported"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.editExportedServices") }}</Button>
|
||||
<pre class="max-h-[60vh] overflow-auto border bg-muted/20 p-3 text-xs">{{ JSON.stringify(exported, null, 2) }}</pre>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Dialog v-model:open="editorOpen"
|
||||
><DialogContent class="max-w-3xl"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ editorMode === "config" ? t("consul.ui.serviceMeshConfigEntry") : editorMode === "intention" ? t("consul.ui.intentions") : t("consul.ui.exportedServices") }}</DialogTitle></DialogHeader
|
||||
><textarea v-model="editorJson" class="h-96 w-full resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none" spellcheck="false" /><DialogFooter
|
||||
><Button variant="outline" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button v-if="canWrite" @click="save">{{ t("consul.ui.applyWithCas") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
<NacosConfigDiffDialog v-model:open="savePreviewOpen" :title="t('consul.ui.reviewMeshChanges')" :before="editorOriginalJson" :after="editorJson" :confirm-label="t('consul.ui.apply')" @confirm="confirmSave" />
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { Download, RefreshCcw } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulCapabilities, ConsulOperatorDocument, ConsulOperatorReadKind } from "@/types/consul";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const kinds: ConsulOperatorReadKind[] = ["autopilot_configuration", "autopilot_health", "autopilot_state", "raft_configuration", "usage", "license", "audit"];
|
||||
const documents = ref<Record<string, ConsulOperatorDocument>>({});
|
||||
const failures = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const writeError = ref("");
|
||||
const snapshot = ref({ data: "", datacenter: "", confirmation: "" });
|
||||
const autopilot = ref({ cleanupDeadServers: true, confirmation: "" });
|
||||
const raft = ref({ serverId: "", address: "", confirmation: "" });
|
||||
const keyring = ref({ operation: "install" as "install" | "use" | "remove", key: "", confirmation: "" });
|
||||
const license = ref({ value: "", confirmation: "" });
|
||||
const settings = computed(() => {
|
||||
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
||||
return external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
});
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const autopilotWriteVisible = computed(() => settings.value.consulOperatorAutopilotWriteEnabled && props.capabilities?.operatorAutopilot === "supported");
|
||||
const raftWriteVisible = computed(() => settings.value.consulOperatorRaftWriteEnabled && props.capabilities?.operatorRaft === "supported");
|
||||
const keyringWriteVisible = computed(() => settings.value.consulOperatorKeyringWriteEnabled && props.capabilities?.operatorKeyring === "supported");
|
||||
const licenseWriteVisible = computed(() => settings.value.consulOperatorLicenseWriteEnabled && props.capabilities?.operatorLicense === "supported");
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
await Promise.all(
|
||||
kinds.map(async (kind) => {
|
||||
try {
|
||||
documents.value[kind] = await api.consulOperatorRead(props.connectionId, kind);
|
||||
delete failures.value[kind];
|
||||
} catch (error) {
|
||||
failures.value[kind] = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function downloadSnapshot() {
|
||||
const snapshot = await api.consulSnapshotGenerate(props.connectionId);
|
||||
const binary = atob(snapshot.dataBase64);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
|
||||
link.download = `consul-${snapshot.datacenter || "snapshot"}.snap`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
async function selectSnapshot(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (bytes.length < 10 || bytes[0] !== 0x1f || bytes[1] !== 0x8b || bytes[2] !== 0x08) {
|
||||
snapshot.value.data = "";
|
||||
writeError.value = "CONSUL_SNAPSHOT_INVALID: Snapshot must be a gzip archive generated by Consul";
|
||||
return;
|
||||
}
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
snapshot.value.data = btoa(binary);
|
||||
}
|
||||
|
||||
async function runWrite(action: () => Promise<void>) {
|
||||
writeError.value = "";
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
writeError.value = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto">
|
||||
<div class="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background px-3">
|
||||
<span class="text-sm font-medium">{{ t("consul.ui.operatorReadOnly") }}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="outline" size="sm" @click="downloadSnapshot"><Download class="mr-1 h-3.5 w-3.5" />{{ t("consul.ui.snapshot") }}</Button
|
||||
><Button variant="ghost" size="icon" class="h-7 w-7" :disabled="loading" :title="t('consul.ui.refresh')" @click="refresh"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 p-3 lg:grid-cols-2">
|
||||
<section v-for="kind in kinds" :key="kind" class="border">
|
||||
<h3 class="border-b px-3 py-2 text-xs font-medium uppercase">{{ kind.replaceAll("_", " ") }}</h3>
|
||||
<p v-if="failures[kind]" class="px-3 py-3 text-xs text-destructive">{{ failures[kind] }}</p>
|
||||
<dl v-else class="divide-y">
|
||||
<div v-for="field in documents[kind]?.fields || []" :key="field.name" class="grid grid-cols-[minmax(8rem,1fr)_2fr] gap-3 px-3 py-2 text-xs">
|
||||
<dt class="text-muted-foreground">{{ field.name }}</dt>
|
||||
<dd class="break-all">{{ field.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="writeError" class="mx-3 mb-3 border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">{{ writeError }}</div>
|
||||
<div v-if="!readOnly" class="grid gap-3 px-3 pb-3 lg:grid-cols-2">
|
||||
<section v-if="settings.consulOperatorSnapshotRestoreEnabled" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.snapshotRestore") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<Input type="file" @change="selectSnapshot" /><Input v-model="snapshot.datacenter" :placeholder="t('consul.ui.targetDatacenter')" /><Input v-model="snapshot.confirmation" :placeholder="t('consul.ui.restoreSnapshotConfirmation', { datacenter: snapshot.datacenter })" /><Button
|
||||
size="sm"
|
||||
:disabled="!snapshot.data"
|
||||
@click="runWrite(() => api.consulSnapshotRestore(props.connectionId, { snapshotBase64: snapshot.data, targetDatacenter: snapshot.datacenter, confirmation: snapshot.confirmation }))"
|
||||
>{{ t("consul.ui.restoreSnapshot") }}</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="autopilotWriteVisible" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.autopilotUpdate") }}</h3>
|
||||
<label class="mb-2 flex items-center gap-2 text-xs"><input v-model="autopilot.cleanupDeadServers" type="checkbox" />{{ t("consul.ui.cleanupDeadServers") }}</label
|
||||
><Input v-model="autopilot.confirmation" :placeholder="t('consul.ui.updateAutopilotConfirmation')" /><Button class="mt-2" size="sm" @click="runWrite(() => api.consulAutopilotUpdate(props.connectionId, { CleanupDeadServers: autopilot.cleanupDeadServers }, autopilot.confirmation))">{{
|
||||
t("consul.ui.updateAutopilot")
|
||||
}}</Button>
|
||||
</section>
|
||||
<section v-if="raftWriteVisible" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.raftOperations") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<Input v-model="raft.serverId" :placeholder="t('consul.ui.serverId')" /><Input v-model="raft.address" :placeholder="t('consul.ui.peerAddress')" /><Input v-model="raft.confirmation" :placeholder="t('consul.ui.reenterIdAddress')" />
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" @click="runWrite(() => api.consulRaftTransfer(props.connectionId, { serverId: raft.serverId || null, address: null, confirmation: raft.confirmation }))">{{ t("consul.ui.transferLeader") }}</Button
|
||||
><Button size="sm" variant="destructive" @click="runWrite(() => api.consulRaftRemove(props.connectionId, { serverId: raft.serverId || null, address: raft.address || null, confirmation: raft.confirmation }))">{{ t("consul.ui.removePeer") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="keyringWriteVisible" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.gossipKeyring") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t("consul.ui.operation") }}</Label
|
||||
><select v-model="keyring.operation" class="h-9 border bg-background px-2 text-sm">
|
||||
<option value="install">{{ t("consul.ui.install") }}</option>
|
||||
<option value="use">{{ t("consul.ui.use") }}</option>
|
||||
<option value="remove">{{ t("consul.ui.remove") }}</option></select
|
||||
><Input v-model="keyring.key" type="password" :placeholder="t('consul.ui.gossipKey')" /><Input v-model="keyring.confirmation" :placeholder="t('consul.ui.changeKeyringConfirmation')" /><Button size="sm" @click="runWrite(() => api.consulKeyringWrite(props.connectionId, keyring))">{{
|
||||
t("consul.ui.changeKeyring")
|
||||
}}</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="licenseWriteVisible" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.licenseUpdate") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<Input v-model="license.value" type="password" :placeholder="t('consul.ui.license')" /><Input v-model="license.confirmation" :placeholder="t('consul.ui.updateLicenseConfirmation')" /><Button
|
||||
size="sm"
|
||||
@click="runWrite(() => api.consulLicenseWrite(props.connectionId, { license: license.value, confirmation: license.confirmation }))"
|
||||
>{{ t("consul.ui.updateLicense") }}</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, Loader2, RefreshCcw, Server } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulAgentIdentity, ConsulAgentMember, ConsulCapabilities, ConsulOperatorDocument } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const capabilities = ref<ConsulCapabilities | null>(null);
|
||||
const identity = ref<ConsulAgentIdentity | null>(null);
|
||||
const leader = ref<string | null>(null);
|
||||
const peers = ref<string[]>([]);
|
||||
const members = ref<ConsulAgentMember[]>([]);
|
||||
const autopilotHealth = ref<ConsulOperatorDocument | null>(null);
|
||||
const errors = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
let sequence = 0;
|
||||
type ProbeState = "loading" | "ready" | "forbidden" | "unsupported" | "error";
|
||||
const probeKeys = ["capabilities", "agent", "leader", "peers", "members", "autopilot"] as const;
|
||||
type ProbeKey = (typeof probeKeys)[number];
|
||||
const probeStates = ref<Record<ProbeKey, ProbeState>>({ capabilities: "loading", agent: "loading", leader: "loading", peers: "loading", members: "loading", autopilot: "loading" });
|
||||
|
||||
const memberSummary = computed(() => {
|
||||
const alive = members.value.filter((member) => member.Status === 1).length;
|
||||
return `${alive}/${members.value.length}`;
|
||||
});
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function classifyProbeError(error: unknown): ProbeState {
|
||||
const detail = message(error).toLowerCase();
|
||||
if (detail.includes("consul_permission_denied") || detail.includes("forbidden") || detail.includes("403")) return "forbidden";
|
||||
if (detail.includes("unsupported") || detail.includes("not found") || detail.includes("404")) return "unsupported";
|
||||
return "error";
|
||||
}
|
||||
|
||||
function probeLabel(key: ProbeKey) {
|
||||
return key === "capabilities" ? t("consul.ui.capabilities") : key === "agent" ? t("consul.ui.agent") : key === "leader" ? t("consul.ui.leader") : key === "peers" ? t("consul.ui.raftPeers") : key === "members" ? t("consul.ui.members") : t("consul.ui.autopilotHealth");
|
||||
}
|
||||
|
||||
function probeStateLabel(state: ProbeState) {
|
||||
return t(`consul.ui.probe${state[0].toUpperCase()}${state.slice(1)}`);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
probeStates.value = { capabilities: "loading", agent: "loading", leader: "loading", peers: "loading", members: "loading", autopilot: "loading" };
|
||||
const tasks = [
|
||||
["capabilities", api.consulCapabilities(props.connectionId), (value: ConsulCapabilities) => (capabilities.value = value)],
|
||||
["agent", api.consulAgentSelf(props.connectionId), (value: ConsulAgentIdentity) => (identity.value = value)],
|
||||
["leader", api.consulStatusLeader(props.connectionId), (value: string) => (leader.value = value)],
|
||||
["peers", api.consulStatusPeers(props.connectionId), (value: string[]) => (peers.value = value)],
|
||||
["members", api.consulAgentMembers(props.connectionId), (value: ConsulAgentMember[]) => (members.value = value)],
|
||||
["autopilot", api.consulOperatorRead(props.connectionId, "autopilot_health"), (value: ConsulOperatorDocument) => (autopilotHealth.value = value)],
|
||||
] as const;
|
||||
await Promise.all(
|
||||
tasks.map(async ([name, promise, assign]) => {
|
||||
try {
|
||||
const value = await promise;
|
||||
if (current === sequence) {
|
||||
(assign as (value: never) => void)(value as never);
|
||||
probeStates.value = { ...probeStates.value, [name]: "ready" };
|
||||
}
|
||||
} catch (error) {
|
||||
if (current === sequence) {
|
||||
errors.value = { ...errors.value, [name]: message(error) };
|
||||
probeStates.value = { ...probeStates.value, [name]: classifyProbeError(error) };
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (current === sequence) loading.value = false;
|
||||
}
|
||||
|
||||
watch(() => props.connectionId, load);
|
||||
onMounted(load);
|
||||
defineExpose({ refresh: () => (void load(), true) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="flex items-center gap-2 text-sm font-medium"><Server class="h-4 w-4" /> {{ t("consul.ui.overview") }}</h2>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7" :disabled="loading" :title="t('consul.ui.refresh')" @click="load"> <Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" /><RefreshCcw v-else class="h-3.5 w-3.5" /> </Button>
|
||||
</div>
|
||||
<div class="grid border-l border-t sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="row in [
|
||||
[t('consul.ui.datacenter'), identity?.datacenter || capabilities?.datacenter || '-'],
|
||||
[t('consul.ui.agentNode'), identity?.node || capabilities?.nodeName || '-'],
|
||||
[t('consul.ui.agentAddress'), identity?.address || '-'],
|
||||
[t('consul.ui.version'), identity?.version || capabilities?.version || '-'],
|
||||
[t('consul.ui.mode'), (identity?.server ?? capabilities?.server) == null ? '-' : (identity?.server ?? capabilities?.server) ? t('consul.ui.server') : t('consul.ui.client')],
|
||||
[t('consul.ui.leader'), leader || '-'],
|
||||
[t('consul.ui.raftPeers'), String(peers.length)],
|
||||
[t('consul.ui.membersAlive'), memberSummary],
|
||||
]"
|
||||
:key="row[0]"
|
||||
class="min-h-16 border-b border-r p-3"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">{{ row[0] }}</div>
|
||||
<div class="mt-1 truncate text-sm font-medium" :title="row[1]">{{ row[1] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid border-l border-t sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="key in probeKeys" :key="key" class="flex min-h-10 items-center justify-between gap-3 border-b border-r px-3 py-2 text-xs">
|
||||
<span class="truncate font-medium">{{ probeLabel(key) }}</span>
|
||||
<span class="shrink-0" :class="probeStates[key] === 'ready' ? 'text-emerald-700 dark:text-emerald-300' : probeStates[key] === 'loading' ? 'text-muted-foreground' : probeStates[key] === 'error' ? 'text-destructive' : 'text-amber-700 dark:text-amber-300'" :title="errors[key] || undefined">
|
||||
{{ probeStateLabel(probeStates[key]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="Object.keys(errors).length" class="mt-4 divide-y border">
|
||||
<div v-for="(error, source) in errors" :key="source" class="flex gap-2 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div>
|
||||
<strong class="mr-1">{{ source }}</strong
|
||||
>{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="autopilotHealth?.fields.length" class="mt-4 overflow-hidden border">
|
||||
<div class="border-b bg-muted/30 px-3 py-2 text-xs font-medium">{{ t("consul.ui.autopilotHealth") }}</div>
|
||||
<dl class="grid sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="field in autopilotHealth.fields" :key="field.name" class="min-w-0 border-b border-r px-3 py-2 text-xs">
|
||||
<dt class="truncate text-muted-foreground">{{ field.name }}</dt>
|
||||
<dd class="mt-1 truncate font-medium" :title="field.value">{{ field.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="mt-4 overflow-hidden border">
|
||||
<div class="border-b bg-muted/30 px-3 py-2 text-xs font-medium">{{ t("consul.ui.members") }}</div>
|
||||
<table class="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-muted-foreground">
|
||||
<th class="px-3 py-2">{{ t("consul.ui.node") }}</th>
|
||||
<th class="px-3 py-2">{{ t("consul.ui.address") }}</th>
|
||||
<th class="w-24 px-3 py-2">{{ t("consul.ui.status") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members" :key="`${member.Name}-${member.Addr}`" class="border-b last:border-0">
|
||||
<td class="truncate px-3 py-2">{{ member.Name }}</td>
|
||||
<td class="truncate px-3 py-2">{{ member.Addr }}:{{ member.Port }}</td>
|
||||
<td class="px-3 py-2">{{ member.Status === 1 ? t("consul.ui.alive") : member.Status === 2 ? t("consul.ui.leaving") : t("consul.ui.failed") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Loader2, Pencil, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulCapabilities, ConsulEnterpriseKind, ConsulEnterpriseWrite, ConsulScopeImpact } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const active = ref<ConsulEnterpriseKind>("namespace");
|
||||
const items = ref<Record<string, unknown>[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const editorOpen = ref(false);
|
||||
const editorName = ref<string | null>(null);
|
||||
const editorJson = ref("{}");
|
||||
const pendingDelete = ref<string | null>(null);
|
||||
const impact = ref<ConsulScopeImpact | null>(null);
|
||||
const impactLoading = ref(false);
|
||||
let impactSequence = 0;
|
||||
let loadSequence = 0;
|
||||
const status = computed(() => (active.value === "namespace" ? props.capabilities?.namespaces : props.capabilities?.partitions));
|
||||
const canWrite = computed(() => status.value === "supported" && !readOnly.value);
|
||||
const activeLabel = computed(() => (active.value === "namespace" ? t("consul.ui.namespace") : t("consul.ui.adminPartition")));
|
||||
function capabilityLabel(value?: string) {
|
||||
if (value === "supported") return t("consul.ui.capabilitySupported");
|
||||
if (value === "unsupported") return t("consul.ui.capabilityUnsupported");
|
||||
if (value === "disabled") return t("consul.ui.capabilityDisabled");
|
||||
if (value === "forbidden") return t("consul.ui.capabilityForbidden");
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const connectionScope = computed(() => {
|
||||
const config = store.getConfig(props.connectionId);
|
||||
const external = (config?.external_config || {}) as Record<string, unknown>;
|
||||
return { dc: String(external.datacenter || external.consulDatacenter || "default"), partition: String(external.partition || external.consulPartition || "default"), namespace: String(external.namespace || external.consulNamespace || "default") };
|
||||
});
|
||||
async function load() {
|
||||
const sequence = ++loadSequence;
|
||||
const connectionId = props.connectionId;
|
||||
const kind = active.value;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
items.value = [];
|
||||
try {
|
||||
const result = await api.consulEnterpriseList(connectionId, kind);
|
||||
if (sequence !== loadSequence || connectionId !== props.connectionId || kind !== active.value) return;
|
||||
items.value = result.items as Record<string, unknown>[];
|
||||
} catch (cause) {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && kind === active.value) error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && kind === active.value) loading.value = false;
|
||||
}
|
||||
}
|
||||
function nameOf(item: Record<string, unknown>) {
|
||||
return String(item.Name || "");
|
||||
}
|
||||
function openCreate() {
|
||||
editorName.value = null;
|
||||
editorJson.value = JSON.stringify({ Name: "", ...(active.value === "namespace" ? { Partition: connectionScope.value.partition } : {}), Description: "", Meta: {} }, null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
function openEdit(item: Record<string, unknown>) {
|
||||
editorName.value = nameOf(item);
|
||||
editorJson.value = JSON.stringify(item, null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
async function save() {
|
||||
try {
|
||||
const item = JSON.parse(editorJson.value) as Record<string, unknown>;
|
||||
const value = { kind: active.value, item } as ConsulEnterpriseWrite;
|
||||
await api.consulEnterpriseApply(props.connectionId, editorName.value, value);
|
||||
editorOpen.value = false;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function inspectDelete(name: string) {
|
||||
const current = ++impactSequence;
|
||||
pendingDelete.value = name;
|
||||
impact.value = null;
|
||||
impactLoading.value = true;
|
||||
try {
|
||||
const value = await api.consulEnterpriseImpact(props.connectionId, active.value, name);
|
||||
if (current === impactSequence && pendingDelete.value === name) impact.value = value;
|
||||
} catch (cause) {
|
||||
if (current === impactSequence) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
pendingDelete.value = null;
|
||||
}
|
||||
} finally {
|
||||
if (current === impactSequence) impactLoading.value = false;
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (!pendingDelete.value || !impact.value?.complete) return;
|
||||
try {
|
||||
await api.consulEnterpriseDelete(props.connectionId, active.value, pendingDelete.value);
|
||||
pendingDelete.value = null;
|
||||
impact.value = null;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
function closeDelete() {
|
||||
impactSequence += 1;
|
||||
pendingDelete.value = null;
|
||||
impact.value = null;
|
||||
impactLoading.value = false;
|
||||
}
|
||||
function impactRows(value: ConsulScopeImpact) {
|
||||
return [
|
||||
["services", value.services],
|
||||
["nodes", value.nodes],
|
||||
["kvKeys", value.kvKeys],
|
||||
["healthChecks", value.healthChecks],
|
||||
["sessions", value.sessions],
|
||||
["configEntries", value.configEntries],
|
||||
["intentions", value.intentions],
|
||||
["peerings", value.peerings],
|
||||
["namespaces", value.namespaces],
|
||||
["aclTokens", value.aclTokens],
|
||||
["aclPolicies", value.aclPolicies],
|
||||
["aclRoles", value.aclRoles],
|
||||
["aclAuthMethods", value.aclAuthMethods],
|
||||
["aclBindingRules", value.aclBindingRules],
|
||||
] as const;
|
||||
}
|
||||
watch([active, () => props.connectionId], () => void load());
|
||||
onMounted(() => void load());
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
impactSequence += 1;
|
||||
});
|
||||
defineExpose({
|
||||
refresh: () => {
|
||||
void load();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="border-b px-4 py-2 text-xs text-muted-foreground">
|
||||
{{ t("consul.ui.datacenter") }}: <b>{{ connectionScope.dc }}</b> · {{ t("consul.ui.adminPartition") }}: <b>{{ connectionScope.partition }}</b> · {{ t("consul.ui.namespace") }}: <b>{{ connectionScope.namespace }}</b>
|
||||
</div>
|
||||
<Tabs v-model="active" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex h-11 items-center justify-between border-b px-3">
|
||||
<TabsList class="h-8 p-0.5"
|
||||
><TabsTrigger value="namespace" class="h-7 px-3 text-xs">{{ t("consul.ui.namespaces") }}</TabsTrigger
|
||||
><TabsTrigger value="partition" class="h-7 px-3 text-xs">{{ t("consul.workspace.partitions") }}</TabsTrigger></TabsList
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button><Button v-if="canWrite" size="sm" class="h-7 gap-1 text-xs" @click="openCreate"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.create") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent v-for="kind in ['namespace', 'partition']" :key="kind" :value="kind" class="m-0 min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="status !== 'supported'" class="border px-3 py-2 text-sm text-muted-foreground">{{ t("consul.ui.enterpriseCapability", { status: capabilityLabel(status) }) }}</div>
|
||||
<div v-else-if="loading" class="flex h-28 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<div v-else-if="error" class="border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ error }}</div>
|
||||
<div v-else class="divide-y border">
|
||||
<div v-for="item in items" :key="nameOf(item)" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ nameOf(item) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ item.Description || "-" }}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('consul.ui.edit')" @click="openEdit(item)"><Pencil class="h-3.5 w-3.5" /></Button
|
||||
><Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" :title="t('consul.ui.inspectImpactAndDelete')" @click="inspectDelete(nameOf(item))"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="!items.length" class="p-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noResources") }}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Dialog v-model:open="editorOpen"
|
||||
><DialogContent class="max-w-2xl"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ editorName ? t("consul.ui.editResource", { kind: activeLabel }) : t("consul.ui.createResource", { kind: activeLabel }) }}</DialogTitle></DialogHeader
|
||||
><textarea v-model="editorJson" class="h-72 w-full resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none" spellcheck="false" /><DialogFooter
|
||||
><Button variant="outline" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button v-if="canWrite" @click="save">{{ t("consul.ui.save") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
<Dialog
|
||||
:open="Boolean(pendingDelete)"
|
||||
@update:open="
|
||||
(value) => {
|
||||
if (!value) closeDelete();
|
||||
}
|
||||
"
|
||||
><DialogContent
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("consul.ui.deleteResource", { label: pendingDelete }) }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div v-if="impactLoading" class="flex min-h-24 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<div v-else-if="impact" class="text-sm">
|
||||
<dl class="grid grid-cols-2 border-l border-t text-xs">
|
||||
<div v-for="[resource, count] in impactRows(impact)" :key="resource" class="flex items-center justify-between gap-3 border-b border-r px-3 py-2">
|
||||
<dt class="truncate text-muted-foreground">{{ t(`consul.ui.impactResource.${resource}`) }}</dt>
|
||||
<dd class="font-mono font-medium">{{ count }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-if="impact.filteredByAcls" class="mt-2 text-destructive">{{ t("consul.ui.impactAclFiltered") }}</p>
|
||||
<p v-if="impact.unavailableResources.length" class="mt-2 break-words text-destructive">{{ t("consul.ui.impactUnavailable", { resources: impact.unavailableResources.join(", ") }) }}</p>
|
||||
<p v-if="!impact.complete" class="mt-2 text-destructive">{{ t("consul.ui.impactBlocked") }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" @click="closeDelete">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="impactLoading || !impact?.complete" @click="remove">{{ t("consul.ui.delete") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,941 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { CircleCheck, Loader2, LockKeyhole, Pause, Plus, Radio, RefreshCcw, Search, ServerCog, Trash2, Wrench } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import ConsulListPagination from "@/components/consul/ConsulListPagination.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { nextWatchIndex } from "@/lib/consul/watchState";
|
||||
import { consulAgentWriteTargetSafe } from "@/lib/consul/agentTarget";
|
||||
import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/lib/consul/pagination";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulAgentIdentity, ConsulAgentService, ConsulAgentServiceRegistration, ConsulCatalogNode, ConsulCatalogServiceNode, ConsulDomainWatchResponse, ConsulHealthCheck, ConsulNodeServices, ConsulScope } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const services = ref<Record<string, string[]>>({});
|
||||
const nodes = ref<ConsulCatalogNode[]>([]);
|
||||
const localServices = ref<Record<string, ConsulAgentService>>({});
|
||||
const localChecks = ref<Record<string, ConsulHealthCheck>>({});
|
||||
const selectedLocalService = ref<ConsulAgentService | null>(null);
|
||||
const identity = ref<ConsulAgentIdentity | null>(null);
|
||||
const agentTarget = computed(() => {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
const raw = config.agentTarget || config.agent_target;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const target = raw as Record<string, unknown>;
|
||||
const node = String(target.node || "").trim();
|
||||
const address = String(target.address || "").trim();
|
||||
return node && address ? { node, address } : null;
|
||||
});
|
||||
const canAgentWrite = computed(() => consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node));
|
||||
const agentTargetStatus = computed<"verifying" | "writable" | "readonly">(() => {
|
||||
if (agentLoading.value && !identity.value) return "verifying";
|
||||
return canAgentWrite.value ? "writable" : "readonly";
|
||||
});
|
||||
const selected = ref("");
|
||||
const selectedNode = ref("");
|
||||
const browseMode = ref<"service" | "node">("service");
|
||||
const browseFilter = ref("");
|
||||
const localServiceFilter = ref("");
|
||||
const instances = ref<ConsulCatalogServiceNode[]>([]);
|
||||
const selectedCatalogInstanceKey = ref("");
|
||||
const nodeServices = ref<ConsulNodeServices | null>(null);
|
||||
const selectedNodeServiceId = ref("");
|
||||
const browsePage = ref(1);
|
||||
const catalogInstancePage = ref(1);
|
||||
const nodeServicePage = ref(1);
|
||||
const localServicePage = ref(1);
|
||||
const errors = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const agentLoading = ref(true);
|
||||
const saving = ref(false);
|
||||
const editorOpen = ref(false);
|
||||
const maintenancePendingId = ref("");
|
||||
const form = ref({ id: "", name: "", tags: "", address: "", port: 0 });
|
||||
let sequence = 0;
|
||||
const watchEnabled = ref(false);
|
||||
const watchPaused = ref(false);
|
||||
const watchIndex = ref<string | null>(null);
|
||||
let watchConnectionId = "";
|
||||
let watchScope: ConsulScope = { datacenter: "", namespace: "", partition: "" };
|
||||
let watchGeneration = 0;
|
||||
let servicesWatchId = "";
|
||||
let instancesWatchId = "";
|
||||
let nodesWatchId = "";
|
||||
let nodeServicesWatchId = "";
|
||||
|
||||
const serviceNames = computed(() => Object.keys(services.value).sort());
|
||||
const nodeNames = computed(() =>
|
||||
nodes.value
|
||||
.map((node) => node.Node)
|
||||
.filter(Boolean)
|
||||
.sort(),
|
||||
);
|
||||
const normalizedBrowseFilter = computed(() => browseFilter.value.trim().toLocaleLowerCase());
|
||||
const filteredServiceNames = computed(() =>
|
||||
serviceNames.value.filter((name) => {
|
||||
const query = normalizedBrowseFilter.value;
|
||||
return !query || `${name} ${services.value[name]?.join(" ") || ""}`.toLocaleLowerCase().includes(query);
|
||||
}),
|
||||
);
|
||||
const filteredNodeNames = computed(() => nodeNames.value.filter((name) => !normalizedBrowseFilter.value || name.toLocaleLowerCase().includes(normalizedBrowseFilter.value)));
|
||||
const localServiceItems = computed(() => Object.values(localServices.value).sort((left, right) => left.Service.localeCompare(right.Service) || left.ID.localeCompare(right.ID)));
|
||||
const normalizedLocalServiceFilter = computed(() => localServiceFilter.value.trim().toLocaleLowerCase());
|
||||
const filteredLocalServiceItems = computed(() =>
|
||||
localServiceItems.value.filter((service) => {
|
||||
const query = normalizedLocalServiceFilter.value;
|
||||
if (!query) return true;
|
||||
const metadata = Object.entries(service.Meta || {})
|
||||
.map(([key, value]) => `${key} ${value}`)
|
||||
.join(" ");
|
||||
return `${service.Service} ${service.ID} ${service.Address} ${service.Port} ${service.Tags.join(" ")} ${metadata}`.toLocaleLowerCase().includes(query);
|
||||
}),
|
||||
);
|
||||
const nodeServiceItems = computed(() => Object.values(nodeServices.value?.Services || {}).sort((left, right) => left.Service.localeCompare(right.Service) || left.ID.localeCompare(right.ID)));
|
||||
const pagedServiceNames = computed(() => paginateConsulItems(filteredServiceNames.value, browsePage.value));
|
||||
const pagedNodeNames = computed(() => paginateConsulItems(filteredNodeNames.value, browsePage.value));
|
||||
const pagedCatalogInstances = computed(() => paginateConsulItems(instances.value, catalogInstancePage.value));
|
||||
const pagedNodeServices = computed(() => paginateConsulItems(nodeServiceItems.value, nodeServicePage.value));
|
||||
const pagedLocalServices = computed(() => paginateConsulItems(filteredLocalServiceItems.value, localServicePage.value));
|
||||
const maintenanceServiceIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
Object.values(localChecks.value)
|
||||
.filter((check) => check.Type === "maintenance" || check.CheckID.startsWith("_service_maintenance:"))
|
||||
.map((check) => check.ServiceID || check.CheckID.slice("_service_maintenance:".length)),
|
||||
),
|
||||
);
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function normalizeCatalogServices(value: Record<string, string[]>): Record<string, string[]> {
|
||||
return Object.fromEntries(Object.entries(value).map(([name, tags]) => [name, [...tags].sort((left, right) => left.localeCompare(right))]));
|
||||
}
|
||||
|
||||
function isServiceInMaintenance(id: string) {
|
||||
return maintenanceServiceIds.value.has(id);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, milliseconds: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function currentScope(): ConsulScope {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
return {
|
||||
datacenter: String(config.datacenter || config.consulDatacenter || config.consul_datacenter || ""),
|
||||
namespace: String(config.namespace || config.consulNamespace || config.consul_namespace || ""),
|
||||
partition: String(config.partition || config.consulPartition || config.consul_partition || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function watchId(kind: string) {
|
||||
return globalThis.crypto?.randomUUID?.() || `${kind}-${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
function watchActive(operationId: string) {
|
||||
return watchEnabled.value && !watchPaused.value && watchConnectionId === props.connectionId && watchGeneration === consulStore.generation && operationId !== "";
|
||||
}
|
||||
|
||||
async function stopWatches() {
|
||||
const operations = [servicesWatchId, instancesWatchId, nodesWatchId, nodeServicesWatchId].filter(Boolean);
|
||||
servicesWatchId = "";
|
||||
instancesWatchId = "";
|
||||
nodesWatchId = "";
|
||||
nodeServicesWatchId = "";
|
||||
await Promise.all(
|
||||
operations.map(async (operationId) => {
|
||||
consulStore.completeOperation(operationId);
|
||||
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, operationId).catch(() => false);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runServicesWatch(operationId: string) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && servicesWatchId === operationId) {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: { kind: "catalogServices" },
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<Record<string, string[]>>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) {
|
||||
services.value = normalizeCatalogServices(result.items);
|
||||
if (!selected.value || !services.value[selected.value]) {
|
||||
selected.value = serviceNames.value[0] || "";
|
||||
await loadInstances();
|
||||
await startInstanceWatch();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
|
||||
async function runInstancesWatch(operationId: string, service: string) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && instancesWatchId === operationId && selected.value === service) {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: { kind: "catalogServiceNodes", service },
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<ConsulCatalogServiceNode[]>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) instances.value = result.items;
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
|
||||
async function runNodesWatch(operationId: string) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && nodesWatchId === operationId && browseMode.value === "node") {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: { kind: "catalogNodes" },
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<ConsulCatalogNode[]>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) {
|
||||
nodes.value = result.items;
|
||||
if (!selectedNode.value || !nodeNames.value.includes(selectedNode.value)) {
|
||||
selectedNode.value = nodeNames.value[0] || "";
|
||||
await loadNodeServices();
|
||||
await startNodeServicesWatch();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
|
||||
async function runNodeServicesWatch(operationId: string, node: string) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && nodeServicesWatchId === operationId && browseMode.value === "node" && selectedNode.value === node) {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: { kind: "catalogNodeServices", node },
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<ConsulNodeServices>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) nodeServices.value = result.items;
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
|
||||
async function startInstanceWatch() {
|
||||
if (!watchEnabled.value || watchPaused.value || !selected.value) return;
|
||||
if (instancesWatchId) await stopInstanceWatch();
|
||||
const operationId = watchId("consul-catalog-instances");
|
||||
instancesWatchId = operationId;
|
||||
consulStore.registerOperation(operationId);
|
||||
void runInstancesWatch(operationId, selected.value);
|
||||
}
|
||||
|
||||
async function stopInstanceWatch() {
|
||||
const operationId = instancesWatchId;
|
||||
instancesWatchId = "";
|
||||
if (!operationId) return;
|
||||
consulStore.completeOperation(operationId);
|
||||
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, operationId).catch(() => false);
|
||||
}
|
||||
|
||||
async function startNodeServicesWatch() {
|
||||
if (!watchEnabled.value || watchPaused.value || !selectedNode.value || browseMode.value !== "node") return;
|
||||
if (nodeServicesWatchId) {
|
||||
const previousOperationId = nodeServicesWatchId;
|
||||
nodeServicesWatchId = "";
|
||||
consulStore.completeOperation(previousOperationId);
|
||||
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, previousOperationId).catch(() => false);
|
||||
}
|
||||
const operationId = watchId("consul-catalog-node-services");
|
||||
nodeServicesWatchId = operationId;
|
||||
consulStore.registerOperation(operationId);
|
||||
void runNodeServicesWatch(operationId, selectedNode.value);
|
||||
}
|
||||
|
||||
async function startWatches() {
|
||||
if ((servicesWatchId || nodesWatchId) && watchEnabled.value && !watchPaused.value) return;
|
||||
watchEnabled.value = true;
|
||||
watchPaused.value = false;
|
||||
watchConnectionId = props.connectionId;
|
||||
watchScope = currentScope();
|
||||
consulStore.bindConnection(watchConnectionId, watchScope);
|
||||
watchGeneration = consulStore.generation;
|
||||
if (browseMode.value === "service") {
|
||||
servicesWatchId = watchId("consul-catalog-services");
|
||||
consulStore.registerOperation(servicesWatchId);
|
||||
void runServicesWatch(servicesWatchId);
|
||||
await startInstanceWatch();
|
||||
} else {
|
||||
nodesWatchId = watchId("consul-catalog-nodes");
|
||||
consulStore.registerOperation(nodesWatchId);
|
||||
void runNodesWatch(nodesWatchId);
|
||||
await startNodeServicesWatch();
|
||||
}
|
||||
}
|
||||
|
||||
async function pauseWatches() {
|
||||
watchPaused.value = true;
|
||||
await stopWatches();
|
||||
}
|
||||
|
||||
async function independent(name: string, action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
errors.value = { ...errors.value, [name]: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
agentLoading.value = true;
|
||||
errors.value = {};
|
||||
// Catalog browsing must remain responsive even when an Agent endpoint is slow or unavailable.
|
||||
// The local-Agent section is supplementary, while Catalog is the primary navigation surface.
|
||||
await Promise.all([
|
||||
independent(t("consul.ui.catalogServices"), async () => {
|
||||
const result = await api.consulCatalogServices(props.connectionId);
|
||||
if (current === sequence) services.value = normalizeCatalogServices(result.items);
|
||||
}),
|
||||
independent(t("consul.ui.catalogNodes"), async () => {
|
||||
const result = await api.consulCatalogNodes(props.connectionId);
|
||||
if (current === sequence) nodes.value = result.items;
|
||||
}),
|
||||
]);
|
||||
if (current === sequence) {
|
||||
if (!selected.value || !services.value[selected.value]) selected.value = serviceNames.value[0] || "";
|
||||
if (!selectedNode.value || !nodeNames.value.includes(selectedNode.value)) selectedNode.value = nodeNames.value[0] || "";
|
||||
loading.value = false;
|
||||
if (browseMode.value === "service") await loadInstances();
|
||||
else await loadNodeServices();
|
||||
}
|
||||
void loadAgentData(current);
|
||||
}
|
||||
|
||||
async function loadAgentData(current = sequence) {
|
||||
agentLoading.value = true;
|
||||
await Promise.all([
|
||||
independent(t("consul.ui.agent"), async () => {
|
||||
const result = await withTimeout(api.consulAgentSelf(props.connectionId), 8_000, t("consul.ui.agentIdentityRequestTimedOut"));
|
||||
if (current === sequence) identity.value = result;
|
||||
}),
|
||||
independent(t("consul.ui.localAgentServices"), async () => {
|
||||
const result = await withTimeout(api.consulAgentServices(props.connectionId), 8_000, t("consul.ui.localServicesRequestTimedOut"));
|
||||
if (current === sequence) localServices.value = result;
|
||||
}),
|
||||
independent(t("consul.ui.localAgentChecks"), async () => {
|
||||
const result = await withTimeout(api.consulAgentChecks(props.connectionId), 8_000, t("consul.ui.localChecksRequestTimedOut"));
|
||||
if (current === sequence) localChecks.value = result;
|
||||
}),
|
||||
]);
|
||||
if (current === sequence) agentLoading.value = false;
|
||||
}
|
||||
|
||||
async function selectService(service: string) {
|
||||
selected.value = service;
|
||||
await loadInstances();
|
||||
await startInstanceWatch();
|
||||
}
|
||||
|
||||
async function selectNode(node: string) {
|
||||
selectedNode.value = node;
|
||||
await loadNodeServices();
|
||||
await startNodeServicesWatch();
|
||||
}
|
||||
|
||||
async function loadInstances() {
|
||||
instances.value = [];
|
||||
catalogInstancePage.value = 1;
|
||||
selectedCatalogInstanceKey.value = "";
|
||||
const service = selected.value;
|
||||
if (!service) return;
|
||||
await independent(t("consul.ui.serviceInstances"), async () => {
|
||||
const result = await api.consulCatalogServiceNodes(props.connectionId, service);
|
||||
if (selected.value === service) instances.value = result.items;
|
||||
});
|
||||
}
|
||||
|
||||
function catalogInstanceKey(item: ConsulCatalogServiceNode) {
|
||||
return `${item.Node}\u0000${item.ServiceID}`;
|
||||
}
|
||||
|
||||
function toggleCatalogInstance(item: ConsulCatalogServiceNode) {
|
||||
const key = catalogInstanceKey(item);
|
||||
selectedCatalogInstanceKey.value = selectedCatalogInstanceKey.value === key ? "" : key;
|
||||
}
|
||||
|
||||
async function loadNodeServices() {
|
||||
nodeServices.value = null;
|
||||
nodeServicePage.value = 1;
|
||||
selectedNodeServiceId.value = "";
|
||||
const node = selectedNode.value;
|
||||
if (!node) return;
|
||||
await independent(t("consul.ui.nodeServices"), async () => {
|
||||
const result = await api.consulCatalogNodeServices(props.connectionId, node);
|
||||
if (selectedNode.value === node) nodeServices.value = result.items;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNodeService(id: string) {
|
||||
selectedNodeServiceId.value = selectedNodeServiceId.value === id ? "" : id;
|
||||
}
|
||||
|
||||
function updateCatalogInstancePage(page: number) {
|
||||
catalogInstancePage.value = page;
|
||||
selectedCatalogInstanceKey.value = "";
|
||||
}
|
||||
|
||||
function updateNodeServicePage(page: number) {
|
||||
nodeServicePage.value = page;
|
||||
selectedNodeServiceId.value = "";
|
||||
}
|
||||
|
||||
function updateLocalServicePage(page: number) {
|
||||
localServicePage.value = page;
|
||||
selectedLocalService.value = null;
|
||||
}
|
||||
|
||||
async function switchBrowseMode(mode: "service" | "node") {
|
||||
if (browseMode.value === mode) return;
|
||||
const resume = watchEnabled.value && !watchPaused.value;
|
||||
await stopWatches();
|
||||
browseMode.value = mode;
|
||||
if (mode === "service") await loadInstances();
|
||||
else await loadNodeServices();
|
||||
if (resume) await startWatches();
|
||||
}
|
||||
|
||||
async function openLocalService(id: string) {
|
||||
if (selectedLocalService.value?.ID === id) {
|
||||
selectedLocalService.value = null;
|
||||
return;
|
||||
}
|
||||
await independent(t("consul.ui.localServiceDetails"), async () => {
|
||||
selectedLocalService.value = await api.consulAgentService(props.connectionId, id);
|
||||
});
|
||||
}
|
||||
|
||||
async function registerService() {
|
||||
if (!canAgentWrite.value || !form.value.name.trim()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const registration: ConsulAgentServiceRegistration = {
|
||||
id: form.value.id.trim(),
|
||||
name: form.value.name.trim(),
|
||||
tags: form.value.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
address: form.value.address.trim(),
|
||||
port: Number(form.value.port) || 0,
|
||||
meta: {},
|
||||
weights: { Passing: 1, Warning: 1 },
|
||||
kind: "",
|
||||
enableTagOverride: false,
|
||||
proxy: null,
|
||||
checks: [],
|
||||
};
|
||||
await api.consulAgentRegisterService(props.connectionId, registration);
|
||||
editorOpen.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
errors.value = { ...errors.value, [t("consul.ui.writeOperation")]: errorMessage(error) };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deregister(id: string) {
|
||||
if (!canAgentWrite.value || !window.confirm(t("consul.ui.deregisterService", { id, node: agentTarget.value?.node || t("consul.ui.targetAgent") }))) return;
|
||||
await independent(t("consul.ui.writeOperation"), async () => {
|
||||
await api.consulAgentDeregisterService(props.connectionId, id);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function maintenance(id: string, enable: boolean) {
|
||||
if (!canAgentWrite.value) return;
|
||||
maintenancePendingId.value = id;
|
||||
try {
|
||||
await independent(t("consul.ui.writeOperation"), async () => {
|
||||
await api.consulAgentServiceMaintenance(props.connectionId, id, enable, t("consul.ui.maintenanceReason"));
|
||||
await loadAgentData();
|
||||
});
|
||||
} finally {
|
||||
maintenancePendingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
watch([browseFilter, browseMode], () => {
|
||||
browsePage.value = 1;
|
||||
});
|
||||
watch(localServiceFilter, () => {
|
||||
localServicePage.value = 1;
|
||||
selectedLocalService.value = null;
|
||||
});
|
||||
watch(filteredServiceNames, (items) => {
|
||||
if (browseMode.value === "service") browsePage.value = clampConsulPage(browsePage.value, items.length);
|
||||
});
|
||||
watch(filteredNodeNames, (items) => {
|
||||
if (browseMode.value === "node") browsePage.value = clampConsulPage(browsePage.value, items.length);
|
||||
});
|
||||
watch(instances, (items) => {
|
||||
catalogInstancePage.value = clampConsulPage(catalogInstancePage.value, items.length);
|
||||
});
|
||||
watch(nodeServiceItems, (items) => {
|
||||
nodeServicePage.value = clampConsulPage(nodeServicePage.value, items.length);
|
||||
});
|
||||
watch(filteredLocalServiceItems, (items) => {
|
||||
localServicePage.value = clampConsulPage(localServicePage.value, items.length);
|
||||
});
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
async () => {
|
||||
await stopWatches();
|
||||
watchEnabled.value = false;
|
||||
watchPaused.value = false;
|
||||
watchIndex.value = null;
|
||||
identity.value = null;
|
||||
agentLoading.value = true;
|
||||
browsePage.value = 1;
|
||||
catalogInstancePage.value = 1;
|
||||
nodeServicePage.value = 1;
|
||||
localServicePage.value = 1;
|
||||
await load();
|
||||
},
|
||||
);
|
||||
onMounted(load);
|
||||
onDeactivated(() => {
|
||||
watchPaused.value = true;
|
||||
void stopWatches();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
void stopWatches();
|
||||
});
|
||||
defineExpose({ refresh: () => (void load(), true) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0">
|
||||
<aside class="flex w-64 shrink-0 flex-col border-r bg-muted/10">
|
||||
<div class="shrink-0 border-b bg-background">
|
||||
<div class="flex h-11 items-center justify-between px-3">
|
||||
<div class="text-sm font-medium">{{ browseMode === "service" ? t("consul.ui.catalogServices") : t("consul.ui.catalogNodes") }}</div>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7" :disabled="loading" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 px-3 pb-2 text-xs">
|
||||
<Button size="sm" class="h-7 rounded-r-none" :variant="browseMode === 'service' ? 'default' : 'outline'" @click="switchBrowseMode('service')">{{ t("consul.ui.service") }}</Button>
|
||||
<Button size="sm" class="h-7 rounded-l-none" :variant="browseMode === 'node' ? 'default' : 'outline'" @click="switchBrowseMode('node')">{{ t("consul.ui.node") }}</Button>
|
||||
</div>
|
||||
<div class="relative px-3 pb-3">
|
||||
<Search class="pointer-events-none absolute left-5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="browseFilter" class="h-8 pl-7 text-xs" :placeholder="browseMode === 'service' ? t('consul.ui.searchAllServices') : t('consul.ui.searchAllNodes')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto py-1">
|
||||
<template v-if="browseMode === 'service'">
|
||||
<Button v-for="name in pagedServiceNames" :key="name" type="button" size="sm" :variant="selected === name ? 'secondary' : 'ghost'" class="h-auto min-h-11 w-full justify-start rounded-none px-3 py-2 text-left text-xs" @click="selectService(name)">
|
||||
<span class="min-w-0 flex-1 truncate font-medium" :title="name">{{ name }}</span>
|
||||
</Button>
|
||||
<div v-if="!filteredServiceNames.length && !loading" class="p-3 text-xs text-muted-foreground">{{ serviceNames.length ? t("consul.ui.noMatchingServices") : t("consul.ui.noVisibleServices") }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button v-for="name in pagedNodeNames" :key="name" type="button" size="sm" :variant="selectedNode === name ? 'secondary' : 'ghost'" class="h-auto min-h-11 w-full justify-start rounded-none px-3 py-2 text-left text-xs" @click="selectNode(name)"
|
||||
><span class="truncate font-medium">{{ name }}</span></Button
|
||||
>
|
||||
<div v-if="!filteredNodeNames.length && !loading" class="p-3 text-xs text-muted-foreground">{{ nodeNames.length ? t("consul.ui.noMatchingNodes") : t("consul.ui.noVisibleNodes") }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="(browseMode === 'service' ? filteredServiceNames.length : filteredNodeNames.length) > CONSUL_LIST_PAGE_SIZE" class="shrink-0 border-t bg-background px-2 py-1">
|
||||
<ConsulListPagination compact :total="browseMode === 'service' ? filteredServiceNames.length : filteredNodeNames.length" :page="browsePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="browsePage = $event" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 flex-1 overflow-auto p-4">
|
||||
<header class="mb-4 flex flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-muted-foreground">{{ browseMode === "service" ? t("consul.ui.catalogServices") : t("consul.ui.catalogNodes") }}</div>
|
||||
<h2 class="mt-0.5 truncate text-lg font-semibold">{{ browseMode === "service" ? selected || t("consul.ui.services") : selectedNode || t("consul.ui.nodes") }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge :variant="agentTargetStatus === 'writable' ? 'secondary' : 'outline'" :class="agentTargetStatus === 'writable' ? 'text-emerald-700 dark:text-emerald-300' : agentTargetStatus === 'readonly' ? 'border-amber-500/40 text-amber-700 dark:text-amber-300' : 'text-muted-foreground'">
|
||||
<Loader2 v-if="agentTargetStatus === 'verifying'" class="mr-1 h-3 w-3 animate-spin" /><ServerCog v-else class="mr-1 h-3 w-3" />{{
|
||||
agentTargetStatus === "verifying" ? t("consul.ui.agentTargetVerifying") : t("consul.ui.agentTarget", { target: canAgentWrite && agentTarget ? `${agentTarget.node} (${agentTarget.address})` : t("consul.ui.unknownWritesDisabled") })
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span v-if="watchEnabled" class="hidden text-xs text-muted-foreground lg:inline">{{ watchPaused ? t("consul.ui.watchPaused") : t("consul.ui.watchingIndex", { index: watchIndex || "1" }) }}</span>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :title="watchEnabled && !watchPaused ? t('consul.ui.pauseWatch') : t('consul.ui.startWatch')" @click="watchEnabled && !watchPaused ? pauseWatches() : startWatches()"
|
||||
><Pause v-if="watchEnabled && !watchPaused" class="h-3.5 w-3.5" /><Radio v-else class="h-3.5 w-3.5" />{{ watchEnabled && !watchPaused ? t("consul.ui.pauseWatch") : t("consul.ui.startWatch") }}</Button
|
||||
>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="!canAgentWrite" :title="canAgentWrite ? undefined : t('consul.ui.unknownWritesDisabled')" @click="editorOpen = !editorOpen"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.register") }}</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="Object.keys(errors).length" class="mb-3 rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div v-for="(error, name) in errors" :key="name">
|
||||
<strong>{{ name }}:</strong> {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="editorOpen" class="mb-4 rounded-lg border bg-muted/10 p-4 sm:p-5">
|
||||
<p class="mb-4 text-xs leading-5 text-amber-700 dark:text-amber-300">{{ t("consul.ui.registrationHint") }}</p>
|
||||
<div class="grid gap-x-5 gap-y-4 md:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.name") }}</Label
|
||||
><Input v-model="form.name" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.id") }}</Label
|
||||
><Input v-model="form.id" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.address") }}</Label
|
||||
><Input v-model="form.address" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.port") }}</Label
|
||||
><Input v-model.number="form.port" class="h-9" type="number" min="0" max="65535" />
|
||||
</div>
|
||||
<div class="space-y-1.5 md:col-span-2">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.tags") }}</Label
|
||||
><Input v-model="form.tags" class="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" size="sm" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button size="sm" :disabled="saving || !canAgentWrite || !form.name.trim()" @click="registerService"><Loader2 v-if="saving" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("consul.ui.registerOn", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between gap-3 border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ browseMode === "service" ? t("consul.ui.catalogInstances") : t("consul.ui.nodeServices") }}</span
|
||||
><Badge variant="secondary">{{ browseMode === "service" ? instances.length : nodeServiceItems.length }}</Badge>
|
||||
</div>
|
||||
<ConsulListPagination v-if="browseMode === 'service'" :total="instances.length" :page="catalogInstancePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="updateCatalogInstancePage" />
|
||||
<ConsulListPagination v-else :total="nodeServiceItems.length" :page="nodeServicePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="updateNodeServicePage" />
|
||||
</div>
|
||||
<div v-if="browseMode === 'service'" class="overflow-x-auto">
|
||||
<table class="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/10 text-left text-muted-foreground">
|
||||
<th class="w-[24%] px-3 py-2.5 font-medium">{{ t("consul.ui.node") }}</th>
|
||||
<th class="w-[17%] px-3 py-2.5 font-medium">{{ t("consul.ui.serviceId") }}</th>
|
||||
<th class="w-[18%] px-3 py-2.5 font-medium">{{ t("consul.ui.address") }}</th>
|
||||
<th class="w-[18%] px-3 py-2.5 font-medium">{{ t("consul.ui.tags") }}</th>
|
||||
<th class="w-[23%] px-3 py-2.5 font-medium">{{ t("consul.ui.metadata") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in pagedCatalogInstances" :key="catalogInstanceKey(item)">
|
||||
<tr
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="cursor-pointer border-b outline-none hover:bg-muted/20 focus-visible:bg-muted/30"
|
||||
:class="selectedCatalogInstanceKey === catalogInstanceKey(item) && 'bg-muted/20'"
|
||||
:aria-expanded="selectedCatalogInstanceKey === catalogInstanceKey(item)"
|
||||
@click="toggleCatalogInstance(item)"
|
||||
@keydown.enter.prevent="toggleCatalogInstance(item)"
|
||||
@keydown.space.prevent="toggleCatalogInstance(item)"
|
||||
>
|
||||
<td class="px-3 py-3">
|
||||
<div class="truncate font-medium">{{ item.Node }}</div>
|
||||
<div
|
||||
class="mt-0.5 truncate text-muted-foreground"
|
||||
:title="
|
||||
Object.entries(item.NodeMeta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')
|
||||
"
|
||||
>
|
||||
{{ item.Datacenter }} · {{ Object.keys(item.NodeMeta).length }} {{ t("consul.ui.metadata") }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 font-mono text-muted-foreground">
|
||||
<span class="block truncate" :title="item.ServiceID">{{ item.ServiceID || "-" }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 font-mono">
|
||||
<span class="block truncate">{{ item.ServiceAddress || item.Address }}:{{ item.ServicePort }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div v-if="item.ServiceTags.length" class="flex flex-wrap gap-1">
|
||||
<Badge v-for="tag in item.ServiceTags" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="block truncate text-muted-foreground"
|
||||
:title="
|
||||
Object.entries(item.ServiceMeta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')
|
||||
"
|
||||
>{{
|
||||
Object.entries(item.ServiceMeta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ") || "-"
|
||||
}}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="selectedCatalogInstanceKey === catalogInstanceKey(item)" class="border-b bg-muted/10">
|
||||
<td colspan="5" class="px-3 py-3">
|
||||
<div class="grid overflow-hidden rounded-md border bg-background sm:grid-cols-4">
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceKind") }}</div>
|
||||
<div class="mt-1 font-medium">{{ item.ServiceKind || t("consul.ui.standard") }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.indexes") }}</div>
|
||||
<div class="mt-1 font-mono">{{ item.CreateIndex }} / {{ item.ModifyIndex }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceWeights") }}</div>
|
||||
<div class="mt-1 font-mono">{{ item.ServiceWeights.Passing }} / {{ item.ServiceWeights.Warning }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.nodeId") }}</div>
|
||||
<div class="mt-1 truncate font-mono" :title="item.ID">{{ item.ID || "-" }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:col-span-4">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.nodeMeta") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<code v-for="[key, value] in Object.entries(item.NodeMeta)" :key="key" class="rounded bg-muted px-1.5 py-0.5">{{ key }}={{ value }}</code
|
||||
><span v-if="!Object.keys(item.NodeMeta).length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 py-2 sm:col-span-4">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceMeta") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<code v-for="[key, value] in Object.entries(item.ServiceMeta)" :key="key" class="rounded bg-muted px-1.5 py-0.5">{{ key }}={{ value }}</code
|
||||
><span v-if="!Object.keys(item.ServiceMeta).length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!instances.length && !loading">
|
||||
<td colspan="5" class="px-3 py-8 text-center text-muted-foreground">{{ t("consul.ui.noVisibleServices") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="p-3 text-xs">
|
||||
<div class="mb-3 flex flex-wrap gap-2 text-muted-foreground">
|
||||
<Badge variant="outline">{{ nodeServices?.Node.Address || "-" }}</Badge
|
||||
><Badge variant="outline">{{ nodeServices?.Node.Datacenter || "-" }}</Badge>
|
||||
</div>
|
||||
<div
|
||||
class="mb-3 rounded-md bg-muted/30 px-2.5 py-2 text-muted-foreground"
|
||||
:title="
|
||||
Object.entries(nodeServices?.Node.NodeMeta || {})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')
|
||||
"
|
||||
>
|
||||
{{ t("consul.ui.nodeMeta") }} ·
|
||||
{{
|
||||
Object.entries(nodeServices?.Node.NodeMeta || {})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ") || "-"
|
||||
}}
|
||||
</div>
|
||||
<div v-for="service in pagedNodeServices" :key="service.ID" class="border-t first:border-t-0">
|
||||
<button type="button" class="w-full px-1 py-2.5 text-left outline-none hover:bg-muted/20 focus-visible:bg-muted/30" :class="selectedNodeServiceId === service.ID && 'bg-muted/20'" :aria-expanded="selectedNodeServiceId === service.ID" @click="toggleNodeService(service.ID)">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
|
||||
><span class="font-medium">{{ service.Service }}</span
|
||||
><code class="text-muted-foreground">{{ service.ID }}</code
|
||||
><span class="font-mono text-muted-foreground">{{ service.Address || nodeServices?.Node.Address }}:{{ service.Port }}</span></span
|
||||
>
|
||||
<span
|
||||
class="mt-1 block truncate text-muted-foreground"
|
||||
:title="
|
||||
Object.entries(service.Meta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')
|
||||
"
|
||||
>{{ t("consul.ui.serviceMeta") }} ·
|
||||
{{
|
||||
Object.entries(service.Meta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ") || "-"
|
||||
}}</span
|
||||
>
|
||||
</button>
|
||||
<div v-if="selectedNodeServiceId === service.ID" class="pb-3">
|
||||
<div class="grid overflow-hidden rounded-md border bg-background sm:grid-cols-2">
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceWeights") }}</div>
|
||||
<div class="mt-1 font-mono">{{ service.Weights.Passing }} / {{ service.Weights.Warning }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.tags") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Badge v-for="tag in service.Tags" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge
|
||||
><span v-if="!service.Tags.length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:col-span-2">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceMeta") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<code v-for="[key, value] in Object.entries(service.Meta)" :key="key" class="rounded bg-muted px-1.5 py-0.5">{{ key }}={{ value }}</code
|
||||
><span v-if="!Object.keys(service.Meta).length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 py-2 sm:col-span-2">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.taggedAddresses") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<code v-for="[key, value] in Object.entries(service.TaggedAddresses)" :key="key" class="rounded bg-muted px-1.5 py-0.5">{{ key }}={{ value.Address }}:{{ value.Port }}</code
|
||||
><span v-if="!Object.keys(service.TaggedAddresses).length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!Object.keys(nodeServices?.Services || {}).length && !loading" class="py-6 text-center text-muted-foreground">{{ t("consul.ui.noVisibleServices") }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-lg border">
|
||||
<div class="flex flex-wrap items-center gap-3 border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex shrink-0 items-center gap-2 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.localAgentServices") }}</span
|
||||
><Badge variant="secondary">{{ localServiceFilter.trim() ? `${filteredLocalServiceItems.length} / ${localServiceItems.length}` : localServiceItems.length }}</Badge>
|
||||
</div>
|
||||
<div class="relative min-w-52 flex-1 sm:max-w-md">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="localServiceFilter" class="h-7 pl-8 text-xs" :placeholder="t('consul.ui.searchLocalServices')" />
|
||||
</div>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-3">
|
||||
<ConsulListPagination :total="filteredLocalServiceItems.length" :page="localServicePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="updateLocalServicePage" />
|
||||
<Badge :variant="agentTargetStatus === 'writable' ? 'secondary' : 'outline'" :class="agentTargetStatus === 'writable' ? 'text-emerald-700 dark:text-emerald-300' : 'text-muted-foreground'"
|
||||
><Loader2 v-if="agentTargetStatus === 'verifying'" class="mr-1 h-3 w-3 animate-spin" /><CircleCheck v-else-if="agentTargetStatus === 'writable'" class="mr-1 h-3 w-3" /><LockKeyhole v-else class="mr-1 h-3 w-3" />{{
|
||||
agentTargetStatus === "verifying" ? t("consul.ui.agentTargetVerifying") : agentTargetStatus === "writable" ? t("consul.ui.agentWriteReady") : t("consul.ui.agentReadOnly")
|
||||
}}</Badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!canAgentWrite && (identity || !agentLoading)" class="flex items-start gap-2 border-b border-amber-300/70 bg-amber-50/70 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/20 dark:text-amber-200">
|
||||
<LockKeyhole class="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{{ t("consul.ui.agentWriteDisabledHint", { target: identity ? `${identity.node} (${identity.address})` : t("consul.ui.unknownWritesDisabled") }) }}</span>
|
||||
</div>
|
||||
<div v-for="service in pagedLocalServices" :key="service.ID" class="border-b last:border-0" :class="selectedLocalService?.ID === service.ID && 'bg-muted/20'">
|
||||
<div class="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<button type="button" class="flex min-w-0 items-start gap-3 text-left" :aria-expanded="selectedLocalService?.ID === service.ID" @click="openLocalService(service.ID)">
|
||||
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted"><ServerCog class="h-4 w-4 text-muted-foreground" /></span
|
||||
><span class="min-w-0 flex-1"
|
||||
><span class="flex min-w-0 flex-wrap items-center gap-2"
|
||||
><span class="truncate font-medium">{{ service.Service }}</span
|
||||
><code class="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{{ service.ID }}</code
|
||||
><Badge v-if="isServiceInMaintenance(service.ID)" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300"><Wrench class="mr-1 h-3 w-3" />{{ t("consul.ui.maintenance") }}</Badge></span
|
||||
><span class="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-muted-foreground"
|
||||
><span class="font-mono">{{ service.Address || identity?.address }}:{{ service.Port }}</span
|
||||
><Badge v-for="tag in service.Tags" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge></span
|
||||
></span
|
||||
>
|
||||
</button>
|
||||
<div v-if="canAgentWrite" class="flex shrink-0 items-center gap-1 lg:border-l lg:pl-3">
|
||||
<Button v-if="!isServiceInMaintenance(service.ID)" size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs" :disabled="maintenancePendingId === service.ID" :title="t('consul.ui.enableMaintenance')" @click.stop="maintenance(service.ID, true)"
|
||||
><Loader2 v-if="maintenancePendingId === service.ID" class="h-3.5 w-3.5 animate-spin" /><Wrench v-else class="h-3.5 w-3.5" />{{ t("consul.ui.enableMaintenance") }}</Button
|
||||
><Button v-else size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs text-amber-700 hover:text-amber-700 dark:text-amber-300" :disabled="maintenancePendingId === service.ID" :title="t('consul.ui.disableMaintenance')" @click.stop="maintenance(service.ID, false)"
|
||||
><Loader2 v-if="maintenancePendingId === service.ID" class="h-3.5 w-3.5 animate-spin" /><CircleCheck v-else class="h-3.5 w-3.5" />{{ t("consul.ui.disableMaintenance") }}</Button
|
||||
><Button size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs text-destructive hover:text-destructive" :title="t('consul.ui.deregister')" @click.stop="deregister(service.ID)"><Trash2 class="h-3.5 w-3.5" />{{ t("consul.ui.deregister") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedLocalService?.ID === service.ID" class="border-t bg-background px-4 py-3 text-xs">
|
||||
<dl class="grid overflow-hidden rounded-md border sm:grid-cols-4">
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.kind") }}</dt>
|
||||
<dd class="mt-1 font-medium">{{ selectedLocalService.Kind || t("consul.ui.standard") }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.datacenter") }}</dt>
|
||||
<dd class="mt-1 font-medium">{{ selectedLocalService.Datacenter || "-" }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.enableTagOverride") }}</dt>
|
||||
<dd class="mt-1 font-mono">{{ selectedLocalService.EnableTagOverride }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.serviceWeights") }}</dt>
|
||||
<dd class="mt-1 font-mono">{{ selectedLocalService.Weights.Passing }} / {{ selectedLocalService.Weights.Warning }}</dd>
|
||||
</div>
|
||||
<div class="px-3 py-2 sm:col-span-4">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.metadata") }}</dt>
|
||||
<dd class="mt-1 break-words font-mono">
|
||||
{{
|
||||
Object.entries(selectedLocalService.Meta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ") || "-"
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!localServiceItems.length && agentLoading" class="px-3 py-8 text-center text-xs text-muted-foreground">{{ t("consul.ui.loading") }}</div>
|
||||
<div v-else-if="!localServiceItems.length" class="flex flex-col items-center gap-2 px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
<span>{{ t("consul.ui.noResources") }}</span
|
||||
><Button size="sm" variant="outline" class="h-7" @click="loadAgentData()"><RefreshCcw class="mr-1 h-3.5 w-3.5" />{{ t("consul.ui.refresh") }}</Button>
|
||||
</div>
|
||||
<div v-else-if="!filteredLocalServiceItems.length" class="px-3 py-8 text-center text-xs text-muted-foreground">{{ t("consul.ui.noMatchingServices") }}</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,648 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Clock3, KeyRound, Loader2, Plus, RefreshCcw, RotateCw, Search, ShieldCheck, Trash2 } from "@lucide/vue";
|
||||
import ConsulListPagination from "@/components/consul/ConsulListPagination.vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/lib/consul/pagination";
|
||||
import { consulSessionRenewDelayMs, parseConsulDurationMs } from "@/lib/consul/sessionRenew";
|
||||
import { normalizeConsulSession } from "@/lib/consul/sessionModel";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulResponseMetadata, ConsulSession, ConsulSessionKeysResponse, ConsulSessionServiceCheck } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
function behaviorLabel(behavior: string) {
|
||||
if (behavior === "delete") return t("consul.ui.behaviorDelete");
|
||||
if (behavior === "release") return t("consul.ui.behaviorRelease");
|
||||
return behavior || t("consul.ui.unknown");
|
||||
}
|
||||
const connectionActive = computed(() => store.connectedIds.has(props.connectionId));
|
||||
|
||||
const sessions = ref<ConsulSession[]>([]);
|
||||
const responseMetadata = ref<ConsulResponseMetadata | null>(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
const createOpen = ref(false);
|
||||
const lockOpen = ref(false);
|
||||
const expandedSessionId = ref("");
|
||||
const heldKeys = ref<Record<string, ConsulSessionKeysResponse>>({});
|
||||
const heldKeysLoading = ref("");
|
||||
|
||||
const search = ref("");
|
||||
const ttlFilter = ref<"any" | "ttl" | "persistent">("any");
|
||||
const behaviorFilter = ref<"any" | "release" | "delete">("any");
|
||||
const page = ref(1);
|
||||
|
||||
const createForm = ref({
|
||||
name: "",
|
||||
node: "",
|
||||
ttl: "30s",
|
||||
lockDelay: "15s",
|
||||
behavior: "release" as "release" | "delete",
|
||||
nodeChecks: "serfHealth",
|
||||
serviceChecks: "",
|
||||
});
|
||||
const lockForm = ref({ key: "", session: "", value: "" });
|
||||
const lockInspection = ref<{ inspected: boolean; found: boolean; modifyIndex: string; owner: string; flags: string }>({ inspected: false, found: false, modifyIndex: "0", owner: "", flags: "0" });
|
||||
const lockInspecting = ref(false);
|
||||
|
||||
const autoRenewId = ref("");
|
||||
let renewTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let renewContext = "";
|
||||
let sequence = 0;
|
||||
|
||||
const filteredSessions = computed(() => {
|
||||
const keyword = search.value.trim().toLocaleLowerCase();
|
||||
return sessions.value.filter((session) => {
|
||||
if (ttlFilter.value === "ttl" && !session.TTL.trim()) return false;
|
||||
if (ttlFilter.value === "persistent" && session.TTL.trim()) return false;
|
||||
if (behaviorFilter.value !== "any" && session.Behavior !== behaviorFilter.value) return false;
|
||||
if (!keyword) return true;
|
||||
const checks = [...session.NodeChecks, ...session.ServiceChecks.flatMap((check) => [check.ID, check.Namespace])];
|
||||
return [session.Name, session.ID, session.Node, session.TTL, session.Behavior, ...checks].join("\n").toLocaleLowerCase().includes(keyword);
|
||||
});
|
||||
});
|
||||
const pagedSessions = computed(() => paginateConsulItems(filteredSessions.value, page.value));
|
||||
const hasFilters = computed(() => Boolean(search.value.trim()) || ttlFilter.value !== "any" || behaviorFilter.value !== "any");
|
||||
|
||||
function message(value: unknown) {
|
||||
return value instanceof Error ? value.message : String(value);
|
||||
}
|
||||
|
||||
async function withUiTimeout<T>(operation: Promise<T>, milliseconds = 15_000): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(t("consul.ui.sessionRequestTimedOut"))), milliseconds);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function currentScopeKey() {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
return [config.datacenter || config.consulDatacenter || config.consul_datacenter, config.namespace || config.consulNamespace || config.consul_namespace, config.partition || config.consulPartition || config.consul_partition].map((value) => String(value || "")).join("\u0000");
|
||||
}
|
||||
|
||||
function currentRenewContext() {
|
||||
return `${props.connectionId}\u0000${consulStore.generation}\u0000${currentScopeKey()}`;
|
||||
}
|
||||
|
||||
function isTtlSession(session: ConsulSession) {
|
||||
return Boolean(session.TTL.trim());
|
||||
}
|
||||
|
||||
function formatLockDelay(value: number) {
|
||||
const seconds = value / 1_000_000_000;
|
||||
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}s`;
|
||||
}
|
||||
|
||||
function sessionChecks(session: ConsulSession) {
|
||||
const nodeChecks = session.NodeChecks.map((check) => `${t("consul.ui.nodeCheckShort")}: ${check}`);
|
||||
const serviceChecks = session.ServiceChecks.map((check) => `${t("consul.ui.serviceCheckShort")}: ${check.ID}${check.Namespace ? ` @ ${check.Namespace}` : ""}`);
|
||||
return [...nodeChecks, ...serviceChecks];
|
||||
}
|
||||
|
||||
function parseServiceChecks(value: string): ConsulSessionServiceCheck[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const [id, namespace = ""] = part.split("@", 2).map((item) => item.trim());
|
||||
return { ID: id, Namespace: namespace };
|
||||
});
|
||||
}
|
||||
|
||||
function validateCreateForm() {
|
||||
const ttl = createForm.value.ttl.trim();
|
||||
const ttlMs = ttl ? parseConsulDurationMs(ttl) : null;
|
||||
if (ttl && (ttlMs === null || ttlMs < 10_000 || ttlMs > 86_400_000)) {
|
||||
return t("consul.ui.invalidSessionTtl");
|
||||
}
|
||||
const delay = createForm.value.lockDelay.trim();
|
||||
const delayMs = delay ? parseConsulDurationMs(delay) : null;
|
||||
if (delay && (delayMs === null || delayMs < 0 || delayMs > 60_000)) {
|
||||
return t("consul.ui.invalidLockDelay");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const value = await withUiTimeout(api.consulSessions(props.connectionId));
|
||||
if (current !== sequence) return;
|
||||
sessions.value = value.items.map(normalizeConsulSession);
|
||||
responseMetadata.value = value.metadata;
|
||||
page.value = clampConsulPage(page.value, filteredSessions.value.length);
|
||||
if (autoRenewId.value && !sessions.value.some((session) => session.ID === autoRenewId.value)) stopAutoRenew();
|
||||
if (!lockForm.value.session && sessions.value.length) lockForm.value.session = sessions.value[0].ID;
|
||||
} catch (value) {
|
||||
if (current === sequence) error.value = message(value);
|
||||
} finally {
|
||||
if (current === sequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession() {
|
||||
if (readOnly.value) return;
|
||||
const validation = validateCreateForm();
|
||||
if (validation) {
|
||||
error.value = validation;
|
||||
return;
|
||||
}
|
||||
if (createForm.value.behavior === "delete" && !window.confirm(t("consul.ui.deleteBehaviorWarning"))) return;
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const created = await api.consulCreateSession(props.connectionId, {
|
||||
name: createForm.value.name,
|
||||
node: createForm.value.node,
|
||||
ttl: createForm.value.ttl.trim() || null,
|
||||
lockDelay: createForm.value.lockDelay.trim() || null,
|
||||
behavior: createForm.value.behavior,
|
||||
nodeChecks: createForm.value.nodeChecks
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
serviceChecks: parseServiceChecks(createForm.value.serviceChecks),
|
||||
});
|
||||
createOpen.value = false;
|
||||
await load();
|
||||
expandedSessionId.value = created.ID;
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renew(id: string) {
|
||||
const session = sessions.value.find((item) => item.ID === id);
|
||||
if (!session || !isTtlSession(session)) {
|
||||
error.value = t("consul.ui.noRenewRequired");
|
||||
stopAutoRenew();
|
||||
return false;
|
||||
}
|
||||
if (readOnly.value || !connectionActive.value) {
|
||||
stopAutoRenew();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const renewed = await api.consulRenewSession(props.connectionId, id);
|
||||
const index = sessions.value.findIndex((item) => item.ID === id);
|
||||
if (index >= 0) sessions.value[index] = renewed;
|
||||
return true;
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
stopAutoRenew();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function destroy(id: string) {
|
||||
if (readOnly.value) return;
|
||||
try {
|
||||
const impact = await api.consulSessionDestroyImpact(props.connectionId, id);
|
||||
if (!impact.complete) {
|
||||
error.value = t("consul.ui.sessionImpactIncomplete");
|
||||
return;
|
||||
}
|
||||
const keys = impact.heldKeys.map((item) => item.key).join(", ") || t("consul.ui.noHeldKeys");
|
||||
if (
|
||||
!window.confirm(
|
||||
t("consul.ui.destroySessionImpact", {
|
||||
id,
|
||||
behavior: impact.session.Behavior,
|
||||
count: impact.heldKeys.length,
|
||||
keys,
|
||||
}),
|
||||
)
|
||||
)
|
||||
return;
|
||||
await api.consulDestroySession(props.connectionId, {
|
||||
id,
|
||||
expectedBehavior: impact.session.Behavior,
|
||||
expectedHeldKeys: impact.heldKeys,
|
||||
});
|
||||
if (autoRenewId.value === id) stopAutoRenew();
|
||||
delete heldKeys.value[id];
|
||||
if (expandedSessionId.value === id) expandedSessionId.value = "";
|
||||
await load();
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetails(id: string) {
|
||||
expandedSessionId.value = expandedSessionId.value === id ? "" : id;
|
||||
}
|
||||
|
||||
async function toggleHeldKeys(id: string) {
|
||||
if (expandedSessionId.value === id && heldKeys.value[id]) {
|
||||
expandedSessionId.value = "";
|
||||
return;
|
||||
}
|
||||
expandedSessionId.value = id;
|
||||
heldKeysLoading.value = id;
|
||||
try {
|
||||
heldKeys.value[id] = await api.consulSessionKeys(props.connectionId, id);
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
} finally {
|
||||
if (heldKeysLoading.value === id) heldKeysLoading.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoRenew(id: string) {
|
||||
const session = sessions.value.find((item) => item.ID === id);
|
||||
if (!session || !isTtlSession(session) || readOnly.value || !connectionActive.value || renewContext !== currentRenewContext()) {
|
||||
stopAutoRenew();
|
||||
return;
|
||||
}
|
||||
renewTimer = setTimeout(async () => {
|
||||
renewTimer = undefined;
|
||||
if (autoRenewId.value !== id || renewContext !== currentRenewContext()) {
|
||||
stopAutoRenew();
|
||||
return;
|
||||
}
|
||||
if (await renew(id)) scheduleAutoRenew(id);
|
||||
}, consulSessionRenewDelayMs(session.TTL));
|
||||
}
|
||||
|
||||
function startAutoRenew(id: string) {
|
||||
stopAutoRenew();
|
||||
const session = sessions.value.find((item) => item.ID === id);
|
||||
if (!session || !isTtlSession(session) || !connectionActive.value) return;
|
||||
autoRenewId.value = id;
|
||||
renewContext = currentRenewContext();
|
||||
scheduleAutoRenew(id);
|
||||
}
|
||||
|
||||
function stopAutoRenew() {
|
||||
if (renewTimer) clearTimeout(renewTimer);
|
||||
renewTimer = undefined;
|
||||
renewContext = "";
|
||||
autoRenewId.value = "";
|
||||
}
|
||||
|
||||
function resetLockInspection() {
|
||||
lockInspection.value = { inspected: false, found: false, modifyIndex: "0", owner: "", flags: "0" };
|
||||
}
|
||||
|
||||
async function inspectLockKey() {
|
||||
const key = lockForm.value.key.trim();
|
||||
if (!key) {
|
||||
resetLockInspection();
|
||||
return false;
|
||||
}
|
||||
lockInspecting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await api.consulGet(props.connectionId, key);
|
||||
lockInspection.value = {
|
||||
inspected: true,
|
||||
found: result.found,
|
||||
modifyIndex: String(result.metadata?.modRevision ?? 0),
|
||||
owner: String(result.metadata?.session ?? ""),
|
||||
flags: String(result.metadata?.flags ?? 0),
|
||||
};
|
||||
return true;
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
return false;
|
||||
} finally {
|
||||
lockInspecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquire() {
|
||||
if (readOnly.value || !lockForm.value.key.trim() || !lockForm.value.session) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (!(await inspectLockKey())) return;
|
||||
if (lockInspection.value.owner && lockInspection.value.owner !== lockForm.value.session) {
|
||||
error.value = t("consul.ui.keyHeldBy", { session: lockInspection.value.owner });
|
||||
return;
|
||||
}
|
||||
await api.consulAcquireLock(props.connectionId, {
|
||||
key: lockForm.value.key.trim(),
|
||||
session: lockForm.value.session,
|
||||
value: { encoding: "utf8", data: lockForm.value.value },
|
||||
flags: null,
|
||||
expectedModifyIndex: lockInspection.value.modifyIndex,
|
||||
});
|
||||
await inspectLockKey();
|
||||
delete heldKeys.value[lockForm.value.session];
|
||||
expandedSessionId.value = "";
|
||||
await toggleHeldKeys(lockForm.value.session);
|
||||
await load();
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function release() {
|
||||
if (readOnly.value || !lockForm.value.key.trim() || !lockForm.value.session) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (!(await inspectLockKey())) return;
|
||||
if (!lockInspection.value.found) {
|
||||
error.value = t("consul.ui.keyNotFound");
|
||||
return;
|
||||
}
|
||||
if (lockInspection.value.owner !== lockForm.value.session) {
|
||||
error.value = lockInspection.value.owner ? t("consul.ui.keyHeldBy", { session: lockInspection.value.owner }) : t("consul.ui.keyUnlocked");
|
||||
return;
|
||||
}
|
||||
await api.consulReleaseLock(props.connectionId, lockForm.value.key.trim(), lockForm.value.session);
|
||||
await inspectLockKey();
|
||||
delete heldKeys.value[lockForm.value.session];
|
||||
await load();
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search.value = "";
|
||||
ttlFilter.value = "any";
|
||||
behaviorFilter.value = "any";
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
stopAutoRenew();
|
||||
expandedSessionId.value = "";
|
||||
heldKeys.value = {};
|
||||
resetLockInspection();
|
||||
void load();
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => [consulStore.generation, currentScopeKey(), connectionActive.value, readOnly.value] as const,
|
||||
() => stopAutoRenew(),
|
||||
);
|
||||
watch([search, ttlFilter, behaviorFilter], () => {
|
||||
page.value = 1;
|
||||
});
|
||||
watch(() => lockForm.value.key, resetLockInspection);
|
||||
onMounted(load);
|
||||
onDeactivated(stopAutoRenew);
|
||||
onBeforeUnmount(stopAutoRenew);
|
||||
defineExpose({ refresh: () => (void load(), true) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto p-4">
|
||||
<header class="mb-4 flex flex-wrap items-start justify-between gap-3 border-b pb-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold">{{ t("consul.ui.sessionsAndLocks") }}</h2>
|
||||
<p class="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">{{ t("consul.ui.sessionDescription") }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="icon" variant="outline" class="h-8 w-8" :disabled="loading" :title="t('consul.ui.refresh')" @click="load">
|
||||
<RefreshCcw class="h-3.5 w-3.5" :class="loading && 'animate-spin'" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly" @click="lockOpen = !lockOpen"> <KeyRound class="h-3.5 w-3.5" />{{ t("consul.ui.lock") }} </Button>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="readOnly" @click="createOpen = !createOpen"> <Plus class="h-3.5 w-3.5" />{{ t("consul.ui.create") }} </Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">{{ error }}</div>
|
||||
|
||||
<section v-if="createOpen" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="border-b bg-muted/30 px-4 py-3 text-sm font-medium">{{ t("consul.ui.createSession") }}</div>
|
||||
<div class="grid gap-x-4 gap-y-3 p-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.name") }}</Label
|
||||
><Input v-model="createForm.name" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.nodeOptional") }}</Label
|
||||
><Input v-model="createForm.node" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.ttlOptional") }}</Label
|
||||
><Input v-model="createForm.ttl" class="h-9" placeholder="30s" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("consul.ui.ttlRangeHint") }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.lockDelay") }}</Label
|
||||
><Input v-model="createForm.lockDelay" class="h-9" placeholder="15s" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("consul.ui.lockDelayHint") }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.behavior") }}</Label
|
||||
><select v-model="createForm.behavior" class="h-9 w-full rounded-md border bg-background px-2 text-sm">
|
||||
<option value="release">{{ t("consul.ui.releaseLocks") }}</option>
|
||||
<option value="delete">{{ t("consul.ui.deleteKeys") }}</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("consul.ui.behaviorHint") }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.nodeChecks") }}</Label
|
||||
><Input v-model="createForm.nodeChecks" class="h-9" placeholder="serfHealth" />
|
||||
</div>
|
||||
<div class="space-y-1.5 md:col-span-2 xl:col-span-3">
|
||||
<Label class="text-xs">{{ t("consul.ui.serviceChecks") }}</Label
|
||||
><Input v-model="createForm.serviceChecks" class="h-9" :placeholder="t('consul.ui.serviceChecksPlaceholder')" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("consul.ui.serviceChecksHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 border-t bg-muted/20 px-4 py-3">
|
||||
<Button size="sm" variant="outline" @click="createOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button size="sm" :disabled="saving" @click="createSession"><Loader2 v-if="saving" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("consul.ui.create") }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="lockOpen" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/30 px-4 py-3">
|
||||
<span class="text-sm font-medium">{{ t("consul.ui.lockManagement") }}</span
|
||||
><span class="text-xs text-muted-foreground">{{ t("consul.ui.lockHint") }}</span>
|
||||
</div>
|
||||
<div class="grid gap-x-4 gap-y-3 p-4 md:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.key") }}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="lockForm.key" class="h-9" @keydown.enter="inspectLockKey" /><Button size="sm" variant="outline" class="h-9 shrink-0" :disabled="lockInspecting || !lockForm.key.trim()" @click="inspectLockKey"
|
||||
><Loader2 v-if="lockInspecting" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("consul.ui.inspectKey") }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.selectSession") }}</Label
|
||||
><select v-model="lockForm.session" class="h-9 w-full rounded-md border bg-background px-2 text-sm">
|
||||
<option value="" disabled>{{ t("consul.ui.selectSession") }}</option>
|
||||
<option v-for="session in sessions" :key="session.ID" :value="session.ID">{{ session.Name || t("consul.ui.unnamedSession") }} · {{ session.ID }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1.5 md:col-span-2">
|
||||
<Label class="text-xs">{{ t("consul.ui.value") }}</Label
|
||||
><textarea v-model="lockForm.value" class="min-h-20 w-full resize-y rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div v-if="lockInspection.inspected" class="md:col-span-2 flex flex-wrap items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs">
|
||||
<Badge variant="secondary">{{ lockInspection.found ? t("consul.ui.keyExists") : t("consul.ui.keyNotFound") }}</Badge>
|
||||
<span>{{ t("consul.ui.modifyIndexValue", { index: lockInspection.modifyIndex }) }}</span>
|
||||
<span v-if="lockInspection.owner" class="text-amber-700 dark:text-amber-300">{{ t("consul.ui.keyHeldBy", { session: lockInspection.owner }) }}</span>
|
||||
<span v-else-if="lockInspection.found" class="text-muted-foreground">{{ t("consul.ui.keyUnlocked") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 border-t bg-muted/20 px-4 py-3">
|
||||
<Button size="sm" variant="outline" @click="lockOpen = false">{{ t("consul.ui.close") }}</Button
|
||||
><Button size="sm" variant="outline" :disabled="saving || !lockForm.key.trim() || !lockForm.session" @click="release">{{ t("consul.ui.releaseMatchingSession") }}</Button
|
||||
><Button size="sm" :disabled="saving || !lockForm.key.trim() || !lockForm.session" @click="acquire">{{ t("consul.ui.acquire") }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 rounded-lg border bg-muted/10 p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative min-w-64 flex-1"><Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /><Input v-model="search" class="h-9 pl-8" :placeholder="t('consul.ui.searchSessions')" /></div>
|
||||
<select v-model="ttlFilter" class="h-9 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allSessionTypes") }}</option>
|
||||
<option value="ttl">{{ t("consul.ui.ttlSessions") }}</option>
|
||||
<option value="persistent">{{ t("consul.ui.persistentSessions") }}</option>
|
||||
</select>
|
||||
<select v-model="behaviorFilter" class="h-9 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allBehaviors") }}</option>
|
||||
<option value="release">{{ t("consul.ui.behaviorRelease") }}</option>
|
||||
<option value="delete">{{ t("consul.ui.behaviorDelete") }}</option>
|
||||
</select>
|
||||
<Button v-if="hasFilters" size="sm" variant="ghost" class="h-9" @click="clearFilters">{{ t("consul.ui.clearFilters") }}</Button>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t("consul.ui.sessionResultCount", { filtered: filteredSessions.length, total: sessions.length }) }}</span>
|
||||
<span v-if="responseMetadata?.filteredByAcls" class="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300"><AlertTriangle class="h-3.5 w-3.5" />{{ t("consul.ui.aclFilteredSessions") }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.sessions") }}</span
|
||||
><Badge variant="secondary">{{ filteredSessions.length }}</Badge>
|
||||
</div>
|
||||
<ConsulListPagination
|
||||
:total="filteredSessions.length"
|
||||
:page="page"
|
||||
:page-size="CONSUL_LIST_PAGE_SIZE"
|
||||
@update:page="
|
||||
page = $event;
|
||||
expandedSessionId = '';
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-for="session in pagedSessions" :key="session.ID" class="border-b last:border-0">
|
||||
<div class="grid cursor-pointer items-center gap-3 px-3 py-3 text-xs transition-colors hover:bg-muted/20 md:grid-cols-[minmax(0,1.4fr)_minmax(7rem,.7fr)_minmax(9rem,.8fr)_auto]" @click="toggleDetails(session.ID)">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" tabindex="-1"><ChevronUp v-if="expandedSessionId === session.ID" class="h-3.5 w-3.5" /><ChevronDown v-else class="h-3.5 w-3.5" /></Button>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ session.Name || t("consul.ui.unnamedSession") }}</div>
|
||||
<div class="truncate font-mono text-[11px] text-muted-foreground">{{ session.ID }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">{{ session.Node || "—" }}</div>
|
||||
<div class="truncate text-muted-foreground">{{ session.Namespace || t("consul.ui.default") }} / {{ session.Partition || t("consul.ui.default") }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Badge :variant="isTtlSession(session) ? 'default' : 'secondary'"><Clock3 class="mr-1 h-3 w-3" />{{ isTtlSession(session) ? session.TTL : t("consul.ui.noTtl") }}</Badge
|
||||
><Badge variant="outline">{{ behaviorLabel(session.Behavior) }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1" @click.stop>
|
||||
<Button size="sm" variant="ghost" class="h-7 gap-1" :disabled="heldKeysLoading === session.ID" @click="toggleHeldKeys(session.ID)"
|
||||
><Loader2 v-if="heldKeysLoading === session.ID" class="h-3.5 w-3.5 animate-spin" /><KeyRound v-else class="h-3.5 w-3.5" />{{ t("consul.ui.heldKeys") }}</Button
|
||||
>
|
||||
<Button v-if="isTtlSession(session)" size="icon" variant="ghost" class="h-7 w-7" :disabled="readOnly || !connectionActive" :title="t('consul.ui.renewNow')" @click="renew(session.ID)"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
<Button v-if="isTtlSession(session)" size="sm" variant="ghost" class="h-7 gap-1" :disabled="readOnly || !connectionActive" :title="t('consul.ui.autoRenewPageHint')" @click="autoRenewId === session.ID ? stopAutoRenew() : startAutoRenew(session.ID)"
|
||||
><RotateCw class="h-3.5 w-3.5" :class="autoRenewId === session.ID && 'animate-spin'" />{{ autoRenewId === session.ID ? t("consul.ui.stop") : t("consul.ui.autoRenew") }}</Button
|
||||
>
|
||||
<span v-else class="px-2 text-[11px] text-muted-foreground">{{ t("consul.ui.noRenewRequired") }}</span>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7 text-destructive" :disabled="readOnly" :title="t('consul.ui.destroy')" @click="destroy(session.ID)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedSessionId === session.ID" class="border-t bg-muted/10 p-3">
|
||||
<dl class="grid overflow-hidden rounded-md border bg-background sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.node") }}</dt>
|
||||
<dd class="mt-1 text-xs font-medium">{{ session.Node || "—" }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.lockDelay") }}</dt>
|
||||
<dd class="mt-1 font-mono text-xs">{{ formatLockDelay(session.LockDelay) }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">TTL</dt>
|
||||
<dd class="mt-1 font-mono text-xs">{{ session.TTL || t("consul.ui.noTtl") }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.behavior") }}</dt>
|
||||
<dd class="mt-1 text-xs font-medium">{{ behaviorLabel(session.Behavior) }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.createIndex") }}</dt>
|
||||
<dd class="mt-1 font-mono text-xs">{{ session.CreateIndex }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.modifyIndexLabel") }}</dt>
|
||||
<dd class="mt-1 font-mono text-xs">{{ session.ModifyIndex }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:col-span-2">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.checks") }}</dt>
|
||||
<dd class="mt-1 flex flex-wrap gap-1">
|
||||
<Badge v-for="check in sessionChecks(session)" :key="check" variant="outline">{{ check }}</Badge
|
||||
><span v-if="!sessionChecks(session).length" class="text-xs text-muted-foreground">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="heldKeys[session.ID] || heldKeysLoading === session.ID" class="mt-3 overflow-hidden rounded-md border bg-background">
|
||||
<div class="flex items-center justify-between border-b bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs font-medium">
|
||||
<KeyRound class="h-3.5 w-3.5" />{{ t("consul.ui.heldKeys") }}<Badge v-if="heldKeys[session.ID]" variant="secondary">{{ heldKeys[session.ID].items.length }}</Badge>
|
||||
</div>
|
||||
<span v-if="heldKeys[session.ID] && !heldKeys[session.ID].complete" class="inline-flex items-center gap-1 text-[11px] text-amber-700 dark:text-amber-300"><AlertTriangle class="h-3.5 w-3.5" />{{ t("consul.ui.heldKeysIncomplete") }}</span
|
||||
><span v-else-if="heldKeys[session.ID]?.complete" class="inline-flex items-center gap-1 text-[11px] text-emerald-700 dark:text-emerald-300"><ShieldCheck class="h-3.5 w-3.5" />{{ t("consul.ui.complete") }}</span>
|
||||
</div>
|
||||
<div v-if="heldKeysLoading === session.ID" class="flex items-center justify-center gap-2 p-4 text-xs text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<template v-else
|
||||
><div v-for="item in heldKeys[session.ID]?.items || []" :key="item.key" class="flex items-center justify-between gap-3 border-b px-3 py-2 font-mono text-xs last:border-0">
|
||||
<span class="min-w-0 break-all">{{ item.key }}</span
|
||||
><span class="shrink-0 text-muted-foreground">{{ t("consul.ui.lockIndex", { index: item.lockIndex }) }}</span>
|
||||
</div>
|
||||
<div v-if="!heldKeys[session.ID]?.items.length" class="p-3 text-xs text-muted-foreground">{{ t("consul.ui.noHeldKeys") }}</div></template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!pagedSessions.length && !loading" class="p-8 text-center text-xs text-muted-foreground">{{ hasFilters ? t("consul.ui.noMatchingSessions") : t("consul.ui.noVisibleSessions") }}</div>
|
||||
<div v-if="loading && !sessions.length" class="flex items-center justify-center gap-2 p-8 text-xs text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { FileSearch, Pencil, Play, Plus, RefreshCcw, Trash2, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulCapabilities, ConsulCoordinate, ConsulEvent, ConsulPreparedQuery, ConsulPreparedQueryExecuteResponse, ConsulPreparedQueryNode } from "@/types/consul";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
type ToolTab = "queries" | "events" | "coordinates";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities?: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
function capabilityLabel(value?: string) {
|
||||
if (value === "supported") return t("consul.ui.capabilitySupported");
|
||||
if (value === "unsupported") return t("consul.ui.capabilityUnsupported");
|
||||
if (value === "disabled") return t("consul.ui.capabilityDisabled");
|
||||
if (value === "forbidden") return t("consul.ui.capabilityForbidden");
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const connectionStore = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const canWriteQueries = computed(() => !readOnly.value && props.capabilities?.preparedQueries === "supported");
|
||||
const canFireEvents = computed(() => !readOnly.value && props.capabilities?.events === "supported");
|
||||
const queryAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.preparedQueries || ""));
|
||||
const eventAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.events || ""));
|
||||
const coordinateAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.coordinates || ""));
|
||||
|
||||
const activeTab = ref<ToolTab>("queries");
|
||||
const queries = ref<ConsulPreparedQuery[]>([]);
|
||||
const events = ref<ConsulEvent[]>([]);
|
||||
const coordinates = ref<ConsulCoordinate[]>([]);
|
||||
const queryLoading = ref(false);
|
||||
const eventLoading = ref(false);
|
||||
const coordinateLoading = ref(false);
|
||||
const queryBusy = ref(false);
|
||||
const eventBusy = ref(false);
|
||||
const queryError = ref("");
|
||||
const eventError = ref("");
|
||||
const coordinateError = ref("");
|
||||
const activeLoading = computed(() => (activeTab.value === "queries" ? queryLoading.value : activeTab.value === "events" ? eventLoading.value : coordinateLoading.value));
|
||||
|
||||
const queryName = ref("");
|
||||
const queryService = ref("");
|
||||
const queryResult = ref<ConsulPreparedQueryExecuteResponse | null>(null);
|
||||
const queryDetails = ref<unknown | null>(null);
|
||||
const editingQueryId = ref("");
|
||||
|
||||
const eventName = ref("");
|
||||
const eventPayload = ref("");
|
||||
const eventNodeFilter = ref("");
|
||||
const eventServiceFilter = ref("");
|
||||
const eventTagFilter = ref("");
|
||||
const eventPayloadBytes = computed(() => new TextEncoder().encode(eventPayload.value).length);
|
||||
const eventValid = computed(() => {
|
||||
const name = eventName.value.trim();
|
||||
return Boolean(name && !name.startsWith("_") && new TextEncoder().encode(name).length <= 256 && eventPayloadBytes.value <= 100);
|
||||
});
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function loadQueries() {
|
||||
if (!queryAvailable.value) return;
|
||||
queryLoading.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
queries.value = await api.consulPreparedQueryList(props.connectionId);
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
if (!eventAvailable.value) return;
|
||||
eventLoading.value = true;
|
||||
eventError.value = "";
|
||||
try {
|
||||
events.value = await api.consulEventList(props.connectionId);
|
||||
} catch (error) {
|
||||
eventError.value = errorMessage(error);
|
||||
} finally {
|
||||
eventLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoordinates() {
|
||||
if (!coordinateAvailable.value) return;
|
||||
coordinateLoading.value = true;
|
||||
coordinateError.value = "";
|
||||
try {
|
||||
coordinates.value = await api.consulCoordinateNodes(props.connectionId);
|
||||
} catch (error) {
|
||||
coordinateError.value = errorMessage(error);
|
||||
} finally {
|
||||
coordinateLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (activeTab.value === "queries") await loadQueries();
|
||||
else if (activeTab.value === "events") await loadEvents();
|
||||
else await loadCoordinates();
|
||||
}
|
||||
|
||||
async function createQuery() {
|
||||
if (!canWriteQueries.value || !queryName.value.trim() || !queryService.value.trim()) return;
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
const input = {
|
||||
name: queryName.value.trim(),
|
||||
session: "",
|
||||
service: { Service: queryService.value.trim(), Near: "", OnlyPassing: false, Tags: [] },
|
||||
};
|
||||
if (editingQueryId.value) await api.consulPreparedQueryUpdate(props.connectionId, editingQueryId.value, input);
|
||||
else await api.consulPreparedQueryCreate(props.connectionId, input);
|
||||
cancelQueryEdit();
|
||||
await loadQueries();
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelQueryEdit() {
|
||||
editingQueryId.value = "";
|
||||
queryName.value = "";
|
||||
queryService.value = "";
|
||||
}
|
||||
|
||||
async function editQuery(query: ConsulPreparedQuery) {
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
const current = await api.consulPreparedQueryRead(props.connectionId, query.ID);
|
||||
queryDetails.value = current;
|
||||
editingQueryId.value = current.ID;
|
||||
queryName.value = current.Name;
|
||||
queryService.value = current.Service.Service;
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function explainQuery(query: ConsulPreparedQuery) {
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
queryDetails.value = await api.consulPreparedQueryExplain(props.connectionId, query.ID);
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteQuery(id: string) {
|
||||
if (!canWriteQueries.value || !confirm(t("consul.ui.deletePreparedQuery", { id }))) return;
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
await api.consulPreparedQueryDelete(props.connectionId, id);
|
||||
if (editingQueryId.value === id) cancelQueryEdit();
|
||||
await loadQueries();
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQuery(query: ConsulPreparedQuery) {
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
queryDetails.value = null;
|
||||
try {
|
||||
queryResult.value = await api.consulPreparedQueryExecute(props.connectionId, { query: query.ID, limit: 100, connect: false });
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function queryEndpoint(item: ConsulPreparedQueryNode) {
|
||||
const address = item.Service.Address || item.Node.Address || "-";
|
||||
return item.Service.Port ? `${address}:${item.Service.Port}` : address;
|
||||
}
|
||||
|
||||
async function fireEvent() {
|
||||
if (!canFireEvents.value || !eventValid.value) return;
|
||||
eventBusy.value = true;
|
||||
eventError.value = "";
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(eventPayload.value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
await api.consulEventFire(props.connectionId, {
|
||||
name: eventName.value.trim(),
|
||||
payloadBase64: btoa(binary),
|
||||
nodeFilter: eventNodeFilter.value.trim(),
|
||||
serviceFilter: eventServiceFilter.value.trim(),
|
||||
tagFilter: eventTagFilter.value.trim(),
|
||||
});
|
||||
eventPayload.value = "";
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
eventError.value = errorMessage(error);
|
||||
} finally {
|
||||
eventBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEventPayload(payload: string | null) {
|
||||
if (!payload) return "";
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(payload), (character) => character.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, () => {
|
||||
void refresh();
|
||||
});
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
queries.value = [];
|
||||
events.value = [];
|
||||
coordinates.value = [];
|
||||
queryResult.value = null;
|
||||
queryDetails.value = null;
|
||||
cancelQueryEdit();
|
||||
void refresh();
|
||||
},
|
||||
);
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex h-10 shrink-0 items-center justify-between border-b px-3">
|
||||
<span class="text-sm font-medium">{{ t("consul.ui.tools") }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="activeLoading" :title="t('consul.ui.refresh')" @click="refresh">
|
||||
<RefreshCcw class="h-3.5 w-3.5" :class="activeLoading ? 'animate-spin' : ''" />
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList class="mx-3 mt-3 h-8 w-fit">
|
||||
<TabsTrigger value="queries">{{ t("consul.ui.preparedQueries") }}</TabsTrigger>
|
||||
<TabsTrigger value="events">{{ t("consul.ui.events") }}</TabsTrigger>
|
||||
<TabsTrigger value="coordinates">{{ t("consul.ui.coordinates") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="queries" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<p class="mb-3 text-sm text-muted-foreground">{{ t("consul.ui.preparedQueriesDescription") }}</p>
|
||||
<div v-if="capabilities && capabilities.preparedQueries !== 'supported'" class="mb-3 rounded-md border p-3 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capabilities.preparedQueries) }) }}</div>
|
||||
<div v-if="queryError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ queryError }}</div>
|
||||
<div v-if="canWriteQueries" class="mb-3 grid grid-cols-[minmax(12rem,1fr)_minmax(12rem,1fr)_auto_auto] gap-2">
|
||||
<Input v-model="queryName" :placeholder="t('consul.ui.queryName')" />
|
||||
<Input v-model="queryService" :placeholder="t('consul.ui.targetService')" />
|
||||
<Button size="sm" :disabled="queryBusy || !queryName.trim() || !queryService.trim()" @click="createQuery"> <Pencil v-if="editingQueryId" class="mr-1 h-3.5 w-3.5" /><Plus v-else class="mr-1 h-3.5 w-3.5" />{{ editingQueryId ? t("consul.ui.update") : t("consul.ui.create") }} </Button>
|
||||
<Button v-if="editingQueryId" variant="ghost" size="icon" class="h-8 w-8" :title="t('consul.ui.cancelEdit')" @click="cancelQueryEdit"><X class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<div v-for="query in queries" :key="query.ID" class="flex min-h-12 items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium">{{ query.Name || query.ID }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ t("consul.ui.targetServiceValue", { service: query.Service.Service }) }} · {{ t("consul.ui.id") }} {{ query.ID }}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="queryBusy" :title="t('consul.ui.execute')" @click="executeQuery(query)"><Play class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="queryBusy" :title="t('consul.ui.explain')" @click="explainQuery(query)"><FileSearch class="h-3.5 w-3.5" /></Button>
|
||||
<Button v-if="canWriteQueries" variant="ghost" size="icon" class="h-7 w-7" :disabled="queryBusy" :title="t('consul.ui.edit')" @click="editQuery(query)"><Pencil class="h-3.5 w-3.5" /></Button>
|
||||
<Button v-if="canWriteQueries" variant="ghost" size="icon" class="h-7 w-7 text-destructive" :disabled="queryBusy" :title="t('consul.ui.delete')" @click="deleteQuery(query.ID)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="!queryLoading && queryAvailable && queries.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noPreparedQueries") }}</div>
|
||||
<div v-if="queryLoading" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.loading") }}</div>
|
||||
</div>
|
||||
<div v-if="queryResult" class="mt-3 overflow-hidden rounded-md border">
|
||||
<div class="border-b bg-muted/20 px-3 py-2 text-sm font-medium">
|
||||
{{ t("consul.ui.queryExecutionResult", { count: queryResult.Nodes.length, datacenter: queryResult.Datacenter || "-" }) }}<span v-if="queryResult.DNS?.TTL" class="ml-2 text-xs font-normal text-muted-foreground">DNS TTL {{ queryResult.DNS.TTL }}</span>
|
||||
</div>
|
||||
<div v-for="item in queryResult.Nodes" :key="`${item.Node.Node}:${item.Service.ID}`" class="grid grid-cols-[minmax(8rem,1fr)_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-3 border-b px-3 py-2 text-sm last:border-b-0">
|
||||
<span class="truncate">{{ item.Node.Node }}</span
|
||||
><span class="truncate font-mono text-xs">{{ item.Service.ID }}</span
|
||||
><span class="truncate font-mono text-xs">{{ queryEndpoint(item) }}</span>
|
||||
</div>
|
||||
<div v-if="queryResult.Nodes.length === 0" class="px-3 py-6 text-center text-sm text-muted-foreground">{{ t("consul.ui.noQueryNodes") }}</div>
|
||||
</div>
|
||||
<div v-if="queryDetails" class="mt-3">
|
||||
<div class="mb-1 text-xs font-medium text-muted-foreground">{{ t("consul.ui.queryExplanation") }}</div>
|
||||
<pre class="max-h-64 overflow-auto rounded-md border bg-muted/20 p-3 text-xs">{{ JSON.stringify(queryDetails, null, 2) }}</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="events" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<p class="mb-3 text-sm text-muted-foreground">{{ t("consul.ui.eventsDescription") }}</p>
|
||||
<div v-if="capabilities && capabilities.events !== 'supported'" class="mb-3 rounded-md border p-3 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capabilities.events) }) }}</div>
|
||||
<div v-if="eventError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ eventError }}</div>
|
||||
<div v-if="canFireEvents" class="mb-3 rounded-md border p-3">
|
||||
<div class="grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,2fr)_auto] gap-2">
|
||||
<Input v-model="eventName" :placeholder="t('consul.ui.eventName')" />
|
||||
<Input v-model="eventPayload" :placeholder="t('consul.ui.payload')" />
|
||||
<Button size="sm" :disabled="eventBusy || !eventValid" @click="fireEvent"><Play class="mr-1 h-3.5 w-3.5" />{{ t("consul.ui.fire") }}</Button>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||
<Input v-model="eventNodeFilter" :placeholder="t('consul.ui.eventNodeFilter')" />
|
||||
<Input v-model="eventServiceFilter" :placeholder="t('consul.ui.eventServiceFilter')" />
|
||||
<Input v-model="eventTagFilter" :placeholder="t('consul.ui.eventTagFilter')" />
|
||||
</div>
|
||||
<div class="mt-2 text-xs" :class="eventPayloadBytes > 100 ? 'text-destructive' : 'text-muted-foreground'">{{ t("consul.ui.eventPayloadBytes", { count: eventPayloadBytes }) }}</div>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<div v-for="event in events" :key="event.ID" class="border-b px-3 py-2 last:border-b-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{{ event.Name }}</span
|
||||
><span class="text-xs text-muted-foreground">LTime {{ event.LTime }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span v-if="event.NodeFilter">{{ t("consul.ui.node") }}: {{ event.NodeFilter }}</span
|
||||
><span v-if="event.ServiceFilter">{{ t("consul.ui.service") }}: {{ event.ServiceFilter }}</span
|
||||
><span v-if="event.TagFilter">{{ t("consul.ui.tags") }}: {{ event.TagFilter }}</span>
|
||||
</div>
|
||||
<div v-if="event.Payload" class="mt-1 break-all rounded bg-muted/30 px-2 py-1 font-mono text-xs">{{ decodeEventPayload(event.Payload) }}</div>
|
||||
</div>
|
||||
<div v-if="!eventLoading && eventAvailable && events.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noEvents") }}</div>
|
||||
<div v-if="eventLoading" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.loading") }}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="coordinates" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<p class="mb-3 text-sm text-muted-foreground">{{ t("consul.ui.coordinatesDescription") }}</p>
|
||||
<div v-if="capabilities && capabilities.coordinates !== 'supported'" class="mb-3 rounded-md border p-3 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capabilities.coordinates) }) }}</div>
|
||||
<div v-if="coordinateError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ coordinateError }}</div>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<div v-if="coordinates.length" class="grid grid-cols-[minmax(10rem,1.5fr)_minmax(7rem,1fr)_repeat(3,minmax(6rem,1fr))] gap-3 border-b bg-muted/20 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{{ t("consul.ui.node") }}</span
|
||||
><span>{{ t("consul.ui.segment") }}</span
|
||||
><span>{{ t("consul.ui.error") }}</span
|
||||
><span>{{ t("consul.ui.adjustment") }}</span
|
||||
><span>{{ t("consul.ui.height") }}</span>
|
||||
</div>
|
||||
<div v-for="item in coordinates" :key="`${item.Node}:${item.Segment}`" class="grid grid-cols-[minmax(10rem,1.5fr)_minmax(7rem,1fr)_repeat(3,minmax(6rem,1fr))] gap-3 border-b px-3 py-2 text-sm last:border-b-0">
|
||||
<span class="truncate font-medium">{{ item.Node }}</span
|
||||
><span class="truncate text-muted-foreground">{{ item.Segment || t("consul.ui.default") }}</span
|
||||
><span class="font-mono text-xs">{{ item.Coord.Error }}</span
|
||||
><span class="font-mono text-xs">{{ item.Coord.Adjustment }}</span
|
||||
><span class="font-mono text-xs">{{ item.Coord.Height }}</span>
|
||||
</div>
|
||||
<div v-if="!coordinateLoading && coordinateAvailable && coordinates.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noCoordinates") }}</div>
|
||||
<div v-if="coordinateLoading" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.loading") }}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Activity, Boxes, KeyRound, Layers3, Network, RefreshCcw, ShieldCheck, Timer, Wrench, Gauge } 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 { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulCapabilities } from "@/types/consul";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { consulMeshWorkspaceVisible } from "@/lib/consul/meshVisibility";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const ConsulKeyBrowser = defineAsyncComponent(() => import("@/components/consul/ConsulKeyBrowser.vue"));
|
||||
const ConsulServices = defineAsyncComponent(() => import("@/components/consul/ConsulServices.vue"));
|
||||
const ConsulHealth = defineAsyncComponent(() => import("@/components/consul/ConsulHealth.vue"));
|
||||
const ConsulSessions = defineAsyncComponent(() => import("@/components/consul/ConsulSessions.vue"));
|
||||
const ConsulAcl = defineAsyncComponent(() => import("@/components/consul/ConsulAcl.vue"));
|
||||
const ConsulScope = defineAsyncComponent(() => import("@/components/consul/ConsulScope.vue"));
|
||||
const ConsulMesh = defineAsyncComponent(() => import("@/components/consul/ConsulMesh.vue"));
|
||||
const ConsulTools = defineAsyncComponent(() => import("@/components/consul/ConsulTools.vue"));
|
||||
const ConsulOperator = defineAsyncComponent(() => import("@/components/consul/ConsulOperator.vue"));
|
||||
|
||||
type WorkspaceTab = "kv" | "services" | "health" | "sessions" | "acl" | "scope" | "mesh" | "tools" | "operator";
|
||||
type Refreshable = { refresh?: () => boolean | Promise<unknown>; focusSearch?: () => boolean };
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const connectionStore = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const { t } = useI18n();
|
||||
const activeTab = ref<WorkspaceTab>("kv");
|
||||
const keyRef = ref<Refreshable>();
|
||||
const servicesRef = ref<Refreshable>();
|
||||
const healthRef = ref<Refreshable>();
|
||||
const sessionsRef = ref<Refreshable>();
|
||||
const aclRef = ref<Refreshable>();
|
||||
const scopeRef = ref<Refreshable>();
|
||||
const meshRef = ref<Refreshable>();
|
||||
const toolsRef = ref<Refreshable>();
|
||||
const operatorRef = ref<Refreshable>();
|
||||
const capabilities = ref<ConsulCapabilities | null>(null);
|
||||
const discoveredDatacenters = ref<string[]>([]);
|
||||
const scopeRevision = ref(0);
|
||||
const scopeOpen = ref(false);
|
||||
const scopeSaving = ref(false);
|
||||
const scopeError = ref("");
|
||||
const scopeDraft = ref({ datacenter: "", partition: "", namespace: "" });
|
||||
const pendingSearchFocus = ref(false);
|
||||
let contextSequence = 0;
|
||||
const activeScope = computed(() => {
|
||||
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
return {
|
||||
datacenter: String(config.datacenter || config.consulDatacenter || config.consul_datacenter || ""),
|
||||
partition: String(config.partition || config.consulPartition || config.consul_partition || ""),
|
||||
namespace: String(config.namespace || config.consulNamespace || config.consul_namespace || ""),
|
||||
};
|
||||
});
|
||||
const datacenterOptions = computed(() => [...new Set([activeScope.value.datacenter, capabilities.value?.datacenter || "", ...discoveredDatacenters.value].map((value) => value.trim()).filter(Boolean))]);
|
||||
const resolvedDatacenter = computed(() => activeScope.value.datacenter || capabilities.value?.datacenter || datacenterOptions.value[0] || "-");
|
||||
const enterpriseScopeAvailable = computed(() => capabilities.value?.namespaces === "supported" || capabilities.value?.partitions === "supported");
|
||||
const aclVisible = computed(() => capabilities.value?.acl !== "disabled");
|
||||
const scopeTabVisible = computed(() => enterpriseScopeAvailable.value);
|
||||
const meshVisible = computed(() => consulMeshWorkspaceVisible(connectionStore.getConfig(props.connectionId)?.external_config));
|
||||
const canChangeScope = computed(() => enterpriseScopeAvailable.value || datacenterOptions.value.length > 1);
|
||||
const scopeLabel = computed(() => (enterpriseScopeAvailable.value ? `${resolvedDatacenter.value} / ${activeScope.value.partition || t("consul.ui.default")} / ${activeScope.value.namespace || t("consul.ui.default")}` : resolvedDatacenter.value));
|
||||
const operatorVisible = computed(() => {
|
||||
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
const capability = capabilities.value;
|
||||
const available = capability && [capability.operatorAutopilot, capability.operatorRaft, capability.operatorKeyring, capability.operatorUsage, capability.operatorLicense, capability.audit].some((status) => status === "supported");
|
||||
return Boolean(config.consulOperatorVisible) && Boolean(available);
|
||||
});
|
||||
function activeHandle() {
|
||||
return { kv: keyRef.value, services: servicesRef.value, health: healthRef.value, sessions: sessionsRef.value, acl: aclRef.value, scope: scopeRef.value, mesh: meshRef.value, tools: toolsRef.value, operator: operatorRef.value }[activeTab.value];
|
||||
}
|
||||
async function loadWorkspaceContext() {
|
||||
const sequence = ++contextSequence;
|
||||
const connectionId = props.connectionId;
|
||||
const [capabilityResult, datacenterResult] = await Promise.allSettled([api.consulCapabilities(connectionId), api.consulCatalogDatacenters(connectionId)]);
|
||||
if (sequence !== contextSequence || connectionId !== props.connectionId) return;
|
||||
capabilities.value = capabilityResult.status === "fulfilled" ? capabilityResult.value : null;
|
||||
discoveredDatacenters.value = datacenterResult.status === "fulfilled" ? [...new Set(datacenterResult.value.map((value) => value.trim()).filter(Boolean))] : [];
|
||||
}
|
||||
function openScopeSwitcher() {
|
||||
scopeDraft.value = {
|
||||
datacenter: resolvedDatacenter.value === "-" ? "" : resolvedDatacenter.value,
|
||||
partition: activeScope.value.partition,
|
||||
namespace: activeScope.value.namespace,
|
||||
};
|
||||
scopeError.value = "";
|
||||
scopeOpen.value = true;
|
||||
}
|
||||
async function applyScope() {
|
||||
const config = connectionStore.getConfig(props.connectionId);
|
||||
if (!config) return;
|
||||
scopeSaving.value = true;
|
||||
scopeError.value = "";
|
||||
const nextScope = {
|
||||
datacenter: scopeDraft.value.datacenter.trim(),
|
||||
partition: scopeDraft.value.partition.trim(),
|
||||
namespace: scopeDraft.value.namespace.trim(),
|
||||
};
|
||||
try {
|
||||
consulStore.bindConnection(props.connectionId, activeScope.value);
|
||||
consulStore.switchScope(nextScope);
|
||||
const currentExternal = config.external_config;
|
||||
const external = currentExternal && typeof currentExternal === "object" && !Array.isArray(currentExternal) ? ({ ...currentExternal } as Record<string, unknown>) : {};
|
||||
external.datacenter = nextScope.datacenter || undefined;
|
||||
external.partition = nextScope.partition || undefined;
|
||||
external.namespace = nextScope.namespace || undefined;
|
||||
await connectionStore.updateConnection({ ...config, external_config: external });
|
||||
scopeRevision.value += 1;
|
||||
scopeOpen.value = false;
|
||||
await loadWorkspaceContext();
|
||||
} catch (error) {
|
||||
scopeError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
scopeSaving.value = false;
|
||||
}
|
||||
}
|
||||
function focusSearch() {
|
||||
activeTab.value = "kv";
|
||||
pendingSearchFocus.value = true;
|
||||
void nextTick(() => {
|
||||
pendingSearchFocus.value = false;
|
||||
keyRef.value?.focusSearch?.();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
function refresh() {
|
||||
const handle = activeHandle();
|
||||
if (!handle?.refresh) return false;
|
||||
void handle.refresh();
|
||||
return true;
|
||||
}
|
||||
watch(keyRef, (value) => {
|
||||
if (value && pendingSearchFocus.value)
|
||||
void nextTick(() => {
|
||||
pendingSearchFocus.value = false;
|
||||
value.focusSearch?.();
|
||||
});
|
||||
});
|
||||
watch([aclVisible, scopeTabVisible, meshVisible], () => {
|
||||
if (activeTab.value === "acl" && !aclVisible.value) activeTab.value = "kv";
|
||||
if (activeTab.value === "scope" && !scopeTabVisible.value) activeTab.value = "kv";
|
||||
if (activeTab.value === "mesh" && !meshVisible.value) activeTab.value = "kv";
|
||||
});
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
consulStore.bindConnection(props.connectionId, activeScope.value);
|
||||
void loadWorkspaceContext();
|
||||
},
|
||||
);
|
||||
onMounted(() => {
|
||||
consulStore.bindConnection(props.connectionId, activeScope.value);
|
||||
void loadWorkspaceContext();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
contextSequence += 1;
|
||||
});
|
||||
defineExpose({ focusSearch, refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs v-model="activeTab" class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div class="flex h-11 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-hidden">
|
||||
<TabsList class="h-8 w-max min-w-max justify-start bg-muted/60 p-0.5">
|
||||
<TabsTrigger value="kv" class="h-7 flex-none gap-1.5 px-3 text-xs"><KeyRound class="h-3.5 w-3.5" />{{ t("consul.workspace.kv") }}</TabsTrigger>
|
||||
<TabsTrigger value="services" class="h-7 flex-none gap-1.5 px-3 text-xs"><Boxes class="h-3.5 w-3.5" />{{ t("consul.workspace.services") }}</TabsTrigger>
|
||||
<TabsTrigger value="health" class="h-7 flex-none gap-1.5 px-3 text-xs"><Activity class="h-3.5 w-3.5" />{{ t("consul.workspace.health") }}</TabsTrigger>
|
||||
<TabsTrigger value="sessions" class="h-7 flex-none gap-1.5 px-3 text-xs"><Timer class="h-3.5 w-3.5" />{{ t("consul.workspace.sessions") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="aclVisible" value="acl" class="h-7 flex-none gap-1.5 px-3 text-xs"><ShieldCheck class="h-3.5 w-3.5" />{{ t("consul.workspace.acl") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="scopeTabVisible" value="scope" class="h-7 flex-none gap-1.5 px-3 text-xs"><Layers3 class="h-3.5 w-3.5" />{{ t("consul.workspace.scope") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="meshVisible" value="mesh" class="h-7 flex-none gap-1.5 px-3 text-xs"><Network class="h-3.5 w-3.5" />{{ t("consul.workspace.mesh") }}</TabsTrigger>
|
||||
<TabsTrigger value="tools" class="h-7 flex-none gap-1.5 px-3 text-xs"><Wrench class="h-3.5 w-3.5" />{{ t("consul.workspace.tools") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="operatorVisible" value="operator" class="h-7 flex-none gap-1.5 px-3 text-xs"><Gauge class="h-3.5 w-3.5" />{{ t("consul.workspace.operator") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button v-if="canChangeScope" variant="ghost" size="sm" class="h-7 max-w-64 truncate px-2 font-mono text-[11px]" :title="t('consul.ui.scope')" @click="openScopeSwitcher">{{ scopeLabel }}</Button>
|
||||
<span v-else class="max-w-64 truncate px-2 font-mono text-[11px]" :title="t('consul.ui.datacenter')">{{ scopeLabel }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('consul.ui.refresh')" @click="refresh"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div :key="`${props.connectionId}:${scopeRevision}`" class="contents">
|
||||
<TabsContent value="kv" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulKeyBrowser ref="keyRef" :connection-id="props.connectionId" /></TabsContent>
|
||||
<TabsContent value="services" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulServices ref="servicesRef" :connection-id="props.connectionId" /></TabsContent>
|
||||
<TabsContent value="health" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulHealth ref="healthRef" :connection-id="props.connectionId" /></TabsContent>
|
||||
<TabsContent value="sessions" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulSessions ref="sessionsRef" :connection-id="props.connectionId" /></TabsContent>
|
||||
<TabsContent v-if="aclVisible" value="acl" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulAcl ref="aclRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
<TabsContent v-if="scopeTabVisible" value="scope" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulScope ref="scopeRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
<TabsContent v-if="meshVisible" value="mesh" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulMesh ref="meshRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
<TabsContent value="tools" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulTools ref="toolsRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
<TabsContent v-if="operatorVisible" value="operator" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulOperator ref="operatorRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
<Dialog v-model:open="scopeOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("consul.ui.scope") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div class="grid gap-3 py-2">
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("consul.ui.datacenter") }}</Label>
|
||||
<Select v-if="datacenterOptions.length > 1" v-model="scopeDraft.datacenter"
|
||||
><SelectTrigger><SelectValue /></SelectTrigger
|
||||
><SelectContent
|
||||
><SelectItem v-for="datacenter in datacenterOptions" :key="datacenter" :value="datacenter">{{ datacenter }}</SelectItem></SelectContent
|
||||
></Select
|
||||
>
|
||||
<Input v-else v-model="scopeDraft.datacenter" readonly />
|
||||
</div>
|
||||
<template v-if="enterpriseScopeAvailable">
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("consul.ui.adminPartition") }}</Label
|
||||
><Input v-model="scopeDraft.partition" :placeholder="t('consul.ui.default')" />
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("consul.ui.namespace") }}</Label
|
||||
><Input v-model="scopeDraft.namespace" :placeholder="t('consul.ui.default')" />
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="scopeError" class="text-sm text-destructive">{{ scopeError }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="scopeSaving" @click="scopeOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button :disabled="scopeSaving" @click="applyScope">{{ t("consul.ui.applyScope") }}</Button></DialogFooter
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ListResult = { items: Array<Record<string, unknown>> };
|
||||
type PendingList = {
|
||||
kind: string;
|
||||
resolve: (value: ListResult) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pendingLists: [] as PendingList[],
|
||||
referencesError: "",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
consulAclList: vi.fn(
|
||||
(_connectionId: string, kind: string) =>
|
||||
new Promise<ListResult>((resolve, reject) => {
|
||||
mocks.pendingLists.push({ kind, resolve, reject });
|
||||
}),
|
||||
),
|
||||
consulAclReferences: vi.fn(async () => {
|
||||
if (mocks.referencesError) throw new Error(mocks.referencesError);
|
||||
return { tokenAccessorIds: [], roleIds: [], bindingRuleIds: [], complete: true };
|
||||
}),
|
||||
consulAclGet: vi.fn(),
|
||||
consulAclApply: vi.fn(),
|
||||
consulAclTokenClone: vi.fn(),
|
||||
consulAclDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ getConfig: () => ({ read_only: false }) }),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConsulAcl from "../ConsulAcl.vue";
|
||||
|
||||
const capabilities = {
|
||||
acl: "supported",
|
||||
authMethods: "supported",
|
||||
bindingRules: "supported",
|
||||
templatedPolicies: "supported",
|
||||
};
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveList(kind: string, items: Array<Record<string, unknown>>) {
|
||||
const index = mocks.pendingLists.findIndex((request) => request.kind === kind);
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
const [request] = mocks.pendingLists.splice(index, 1);
|
||||
request.resolve({ items });
|
||||
}
|
||||
|
||||
function clickButton(host: HTMLElement, label: string) {
|
||||
const button = Array.from(host.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === label);
|
||||
expect(button).toBeTruthy();
|
||||
button!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
mocks.pendingLists.splice(0);
|
||||
mocks.referencesError = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConsulAcl interactions", () => {
|
||||
it("ignores a stale ACL list response after switching resource kinds", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulAcl, { connectionId: "connection-1", capabilities });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
resolveList("token", []);
|
||||
await settle();
|
||||
clickButton(host, "consul.ui.policies");
|
||||
await settle();
|
||||
clickButton(host, "consul.ui.roles");
|
||||
await settle();
|
||||
|
||||
resolveList("role", [{ ID: "role-1", Name: "current-role" }]);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("current-role");
|
||||
|
||||
resolveList("policy", [{ ID: "policy-1", Name: "stale-policy" }]);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("current-role");
|
||||
expect(host.textContent).not.toContain("stale-policy");
|
||||
});
|
||||
|
||||
it("shows an ACL operation error instead of failing silently", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulAcl, { connectionId: "connection-1", capabilities });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
resolveList("token", [{ AccessorID: "token-1", Description: "test-token" }]);
|
||||
await settle();
|
||||
mocks.referencesError = "reference lookup failed";
|
||||
const deleteButton = host.querySelector('button[title="consul.ui.deleteAndInspectReferences"]');
|
||||
expect(deleteButton).toBeTruthy();
|
||||
deleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).toContain("reference lookup failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type PendingList = {
|
||||
kind: string;
|
||||
resolve: (value: { items: Array<Record<string, unknown>> }) => void;
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({ pendingLists: [] as PendingList[] }));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
consulEnterpriseList: vi.fn(
|
||||
(_connectionId: string, kind: string) =>
|
||||
new Promise((resolve) => {
|
||||
mocks.pendingLists.push({ kind, resolve });
|
||||
}),
|
||||
),
|
||||
consulEnterpriseApply: vi.fn(),
|
||||
consulEnterpriseImpact: vi.fn(),
|
||||
consulEnterpriseDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: () => ({ read_only: false, external_config: { datacenter: "dc1" } }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConsulScope from "../ConsulScope.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveList(kind: string, items: Array<Record<string, unknown>>) {
|
||||
const index = mocks.pendingLists.findIndex((request) => request.kind === kind);
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
const [request] = mocks.pendingLists.splice(index, 1);
|
||||
request.resolve({ items });
|
||||
}
|
||||
|
||||
function activateTab(host: HTMLElement, label: string) {
|
||||
const button = Array.from(host.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === label);
|
||||
expect(button).toBeTruthy();
|
||||
button!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
mocks.pendingLists.splice(0);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConsulScope interactions", () => {
|
||||
it("does not let a stale partition request overwrite the active namespace list", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulScope, {
|
||||
connectionId: "connection-1",
|
||||
capabilities: { namespaces: "supported", partitions: "supported" },
|
||||
});
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
resolveList("namespace", []);
|
||||
await settle();
|
||||
activateTab(host, "consul.workspace.partitions");
|
||||
await settle();
|
||||
activateTab(host, "consul.ui.namespaces");
|
||||
await settle();
|
||||
|
||||
resolveList("namespace", [{ Name: "current-namespace" }]);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("current-namespace");
|
||||
|
||||
resolveList("partition", [{ Name: "stale-partition" }]);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("current-namespace");
|
||||
expect(host.textContent).not.toContain("stale-partition");
|
||||
});
|
||||
});
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const agentChecks: Record<string, Record<string, unknown>> = {};
|
||||
return {
|
||||
connectionConfig: { base_url: "http://127.0.0.1:8500", external_config: {} } as Record<string, unknown>,
|
||||
agentChecks,
|
||||
serviceMaintenance: vi.fn(async (_connectionId: string, id: string, enable: boolean) => {
|
||||
const checkId = `_service_maintenance:${id}`;
|
||||
if (enable) {
|
||||
agentChecks[checkId] = {
|
||||
Node: "node-1",
|
||||
CheckID: checkId,
|
||||
Name: "Service Maintenance Mode",
|
||||
Status: "critical",
|
||||
Notes: "test",
|
||||
Output: "",
|
||||
ServiceID: id,
|
||||
ServiceName: "dbx-demo-api",
|
||||
ServiceTags: ["demo"],
|
||||
Type: "maintenance",
|
||||
ExposedPort: 0,
|
||||
Definition: {},
|
||||
CreateIndex: 0,
|
||||
ModifyIndex: 0,
|
||||
Maintenance: true,
|
||||
};
|
||||
} else {
|
||||
delete agentChecks[checkId];
|
||||
}
|
||||
return { target: { node: "node-1", address: "127.0.0.1" } };
|
||||
}),
|
||||
catalogServiceNodes: vi.fn(async (_connectionId: string, service: string) => ({
|
||||
items: [
|
||||
{
|
||||
ID: "node-1",
|
||||
Node: "node-1",
|
||||
Address: "127.0.0.1",
|
||||
Datacenter: "dc1",
|
||||
TaggedAddresses: {},
|
||||
NodeMeta: { zone: "demo-zone" },
|
||||
ServiceKind: "",
|
||||
ServiceID: `${service}-1`,
|
||||
ServiceName: service,
|
||||
ServiceTags: ["demo"],
|
||||
ServiceAddress: "127.0.0.1",
|
||||
ServicePort: 19080,
|
||||
ServiceMeta: { owner: "DBX" },
|
||||
ServiceTaggedAddresses: {},
|
||||
ServiceWeights: { Passing: 1, Warning: 1 },
|
||||
CreateIndex: 1,
|
||||
ModifyIndex: 1,
|
||||
},
|
||||
],
|
||||
metadata: { index: "1", knownLeader: true, lastContact: 0, queryBackend: null },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
consulCatalogServices: vi.fn(async () => ({
|
||||
items: { consul: [], "dbx-demo-api": ["v1", "demo", "api"] },
|
||||
metadata: { index: "1", knownLeader: true, lastContact: 0, queryBackend: null },
|
||||
})),
|
||||
consulCatalogNodes: vi.fn(async () => ({
|
||||
items: [{ ID: "node-1", Node: "node-1", Address: "127.0.0.1", Datacenter: "dc1", TaggedAddresses: {}, NodeMeta: {}, CreateIndex: 1, ModifyIndex: 1 }],
|
||||
metadata: { index: "1", knownLeader: true, lastContact: 0, queryBackend: null },
|
||||
})),
|
||||
consulCatalogServiceNodes: mocks.catalogServiceNodes,
|
||||
consulCatalogNodeServices: vi.fn(async () => ({
|
||||
items: {
|
||||
Node: { ID: "node-1", Node: "node-1", Address: "127.0.0.1", Datacenter: "dc1", TaggedAddresses: {}, NodeMeta: {}, CreateIndex: 1, ModifyIndex: 1 },
|
||||
Services: {
|
||||
"dbx-demo-node-service-1": {
|
||||
ID: "dbx-demo-node-service-1",
|
||||
Service: "dbx-demo-node-service",
|
||||
Tags: ["demo", "node"],
|
||||
Address: "127.0.0.1",
|
||||
TaggedAddresses: { lan: { Address: "127.0.0.1", Port: 19090 } },
|
||||
Meta: { owner: "DBX" },
|
||||
Port: 19090,
|
||||
Weights: { Passing: 2, Warning: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: { index: "1", knownLeader: true, lastContact: 0, queryBackend: null },
|
||||
})),
|
||||
consulAgentSelf: vi.fn(async () => ({ node: "node-1", address: "127.0.0.1", datacenter: "dc1", version: "2.0.2", server: true, revision: null, segment: null })),
|
||||
consulAgentServices: vi.fn(async () => ({
|
||||
"dbx-demo-api-1": { Kind: "", ID: "dbx-demo-api-1", Service: "dbx-demo-api", Tags: ["demo"], Meta: {}, Port: 19080, Address: "127.0.0.1", TaggedAddresses: {}, Weights: { Passing: 1, Warning: 1 }, EnableTagOverride: false, Datacenter: "dc1" },
|
||||
})),
|
||||
consulAgentChecks: vi.fn(async () => ({ ...mocks.agentChecks })),
|
||||
consulAgentService: vi.fn(async (_connectionId: string, id: string) => ({
|
||||
Kind: "",
|
||||
ID: id,
|
||||
Service: "dbx-demo-api",
|
||||
Tags: ["demo"],
|
||||
Meta: { owner: "DBX" },
|
||||
Port: 19080,
|
||||
Address: "127.0.0.1",
|
||||
TaggedAddresses: {},
|
||||
Weights: { Passing: 1, Warning: 1 },
|
||||
EnableTagOverride: false,
|
||||
Datacenter: "dc1",
|
||||
})),
|
||||
consulAgentRegisterService: vi.fn(),
|
||||
consulAgentDeregisterService: vi.fn(),
|
||||
consulAgentServiceMaintenance: mocks.serviceMaintenance,
|
||||
consulDomainWatch: vi.fn(),
|
||||
consulCancelBlocking: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: () => mocks.connectionConfig,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/consulStore", () => ({
|
||||
useConsulStore: () => ({
|
||||
generation: 1,
|
||||
bindConnection: vi.fn(),
|
||||
registerOperation: vi.fn(),
|
||||
completeOperation: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConsulServices from "../ConsulServices.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
mocks.connectionConfig = { base_url: "http://127.0.0.1:8500", external_config: {} };
|
||||
for (const key of Object.keys(mocks.agentChecks)) delete mocks.agentChecks[key];
|
||||
});
|
||||
|
||||
describe("ConsulServices interactions", () => {
|
||||
it("uses a neutral verification state before the Agent identity resolves", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
|
||||
expect(host.textContent).toContain("consul.ui.agentTargetVerifying");
|
||||
expect(host.textContent).not.toContain("consul.ui.agentReadOnly");
|
||||
});
|
||||
|
||||
it("selects a Catalog service and renders its instances on click", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const serviceButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("dbx-demo-api"));
|
||||
expect(serviceButton).toBeTruthy();
|
||||
expect(serviceButton!.textContent).toBe("dbx-demo-api");
|
||||
serviceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector("h2")?.textContent).toContain("dbx-demo-api");
|
||||
expect(host.textContent).toContain("dbx-demo-api-1");
|
||||
expect(mocks.catalogServiceNodes).toHaveBeenLastCalledWith("connection-1", "dbx-demo-api");
|
||||
|
||||
const catalogRow = host.querySelector('tr[role="button"]');
|
||||
expect(catalogRow).toBeTruthy();
|
||||
catalogRow!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(host.textContent).toContain("consul.ui.nodeMeta");
|
||||
expect(host.textContent).toContain("zone=demo-zone");
|
||||
expect(host.textContent).toContain("consul.ui.serviceMeta");
|
||||
expect(catalogRow!.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
catalogRow!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(host.textContent).not.toContain("zone=demo-zone");
|
||||
expect(catalogRow!.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps local service cards interactive while explaining read-only Agent actions", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).toContain("consul.ui.agentReadOnly");
|
||||
expect(host.textContent).toContain("consul.ui.agentWriteDisabledHint");
|
||||
expect(host.textContent).not.toContain("consul.ui.enableMaintenance");
|
||||
|
||||
const localServiceButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("dbx-demo-api-1"));
|
||||
expect(localServiceButton).toBeTruthy();
|
||||
expect(localServiceButton!.hasAttribute("disabled")).toBe(false);
|
||||
localServiceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).toContain("consul.ui.enableTagOverride");
|
||||
expect(host.textContent).toContain("consul.ui.serviceWeights");
|
||||
expect(localServiceButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
localServiceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).not.toContain("consul.ui.enableTagOverride");
|
||||
expect(host.textContent).not.toContain("consul.ui.serviceWeights");
|
||||
expect(localServiceButton!.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows and toggles the current service maintenance state", async () => {
|
||||
mocks.connectionConfig = {
|
||||
base_url: "http://127.0.0.1:8500",
|
||||
external_config: {
|
||||
serverAddr: "http://127.0.0.1:8500",
|
||||
agentTarget: { node: "node-1", address: "127.0.0.1" },
|
||||
},
|
||||
};
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const enableButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("consul.ui.enableMaintenance"));
|
||||
expect(enableButton).toBeTruthy();
|
||||
enableButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(mocks.serviceMaintenance).toHaveBeenLastCalledWith("connection-1", "dbx-demo-api-1", true, "consul.ui.maintenanceReason");
|
||||
expect(host.textContent).toContain("consul.ui.maintenance");
|
||||
const disableButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("consul.ui.disableMaintenance"));
|
||||
expect(disableButton).toBeTruthy();
|
||||
disableButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(mocks.serviceMaintenance).toHaveBeenLastCalledWith("connection-1", "dbx-demo-api-1", false, "consul.ui.maintenanceReason");
|
||||
expect(host.textContent).not.toContain("consul.ui.maintenance");
|
||||
expect(host.textContent).toContain("consul.ui.enableMaintenance");
|
||||
});
|
||||
|
||||
it("expands and collapses full service details in Catalog node view", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const nodeTab = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "consul.ui.node");
|
||||
expect(nodeTab).toBeTruthy();
|
||||
nodeTab!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
const nodeServiceButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("dbx-demo-node-service-1"));
|
||||
expect(nodeServiceButton).toBeTruthy();
|
||||
nodeServiceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(host.textContent).toContain("owner=DBX");
|
||||
expect(host.textContent).toContain("consul.ui.serviceWeights");
|
||||
expect(host.textContent).toContain("lan=127.0.0.1:19090");
|
||||
expect(nodeServiceButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
nodeServiceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(host.textContent).not.toContain("lan=127.0.0.1:19090");
|
||||
expect(nodeServiceButton!.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const services = readFileSync(new URL("../ConsulServices.vue", import.meta.url), "utf8");
|
||||
const health = readFileSync(new URL("../ConsulHealth.vue", import.meta.url), "utf8");
|
||||
const overview = readFileSync(new URL("../ConsulOverview.vue", import.meta.url), "utf8");
|
||||
const sessions = readFileSync(new URL("../ConsulSessions.vue", import.meta.url), "utf8");
|
||||
const scope = readFileSync(new URL("../ConsulScope.vue", import.meta.url), "utf8");
|
||||
const keyBrowser = readFileSync(new URL("../ConsulKeyBrowser.vue", import.meta.url), "utf8");
|
||||
const mesh = readFileSync(new URL("../ConsulMesh.vue", import.meta.url), "utf8");
|
||||
const tools = readFileSync(new URL("../ConsulTools.vue", import.meta.url), "utf8");
|
||||
const operator = readFileSync(new URL("../ConsulOperator.vue", import.meta.url), "utf8");
|
||||
const workspace = readFileSync(new URL("../ConsulWorkspace.vue", import.meta.url), "utf8");
|
||||
const kvBrowser = readFileSync(new URL("../../kv/KvKeyBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("Consul Catalog and Health watch views", () => {
|
||||
it("watches both service and node Catalog resources and cancels them on teardown", () => {
|
||||
for (const target of ["catalogServices", "catalogServiceNodes", "catalogNodes", "catalogNodeServices"]) {
|
||||
expect(services).toContain(`kind: "${target}"`);
|
||||
}
|
||||
expect(services).toMatch(/onBeforeUnmount\(\(\) => \{\s*void stopWatches\(\);\s*\}\);/);
|
||||
expect(services).toContain("switchBrowseMode");
|
||||
});
|
||||
|
||||
it("supports state, node, and service Health resources with a scoped watch", () => {
|
||||
for (const target of ["healthState", "healthNode", "healthServiceInstances"]) {
|
||||
expect(health).toContain(`kind: "${target}"`);
|
||||
}
|
||||
expect(health).toContain('browseMode = ref<"state" | "node" | "service">');
|
||||
expect(health).toMatch(/onBeforeUnmount\(\(\) => \{\s*void stopWatch\(\);\s*\}\);/);
|
||||
expect(health).toContain("selectedCheck");
|
||||
expect(health).toContain("selectedCheck.Definition");
|
||||
});
|
||||
|
||||
it("filters and paginates Health results without querying on every target keystroke", () => {
|
||||
for (const state of ["browseNameDraft", "keywordFilter", "statusFilter", "typeFilter"]) {
|
||||
expect(health).toContain(`const ${state} = ref(`);
|
||||
}
|
||||
for (const collection of ["filteredChecks", "filteredLocalChecks", "pagedChecks", "pagedLocalChecks", "pagedServiceInstances"]) {
|
||||
expect(health).toContain(`const ${collection} = computed(`);
|
||||
}
|
||||
expect(health).toContain('v-model="browseNameDraft"');
|
||||
expect(health).toContain('@keydown.enter="applyBrowseTarget"');
|
||||
expect(health).toContain("t('consul.ui.searchHealthChecks')");
|
||||
expect(health).toContain("<ConsulListPagination");
|
||||
expect(health).not.toContain("watch([state, browseMode, browseName]");
|
||||
});
|
||||
|
||||
it("shows Catalog metadata and independent Overview probe states", () => {
|
||||
expect(services).toContain("item.NodeMeta");
|
||||
expect(services).toContain("item.ServiceMeta");
|
||||
expect(services).toContain("nodeServices?.Node.NodeMeta");
|
||||
for (const state of ["loading", "ready", "forbidden", "unsupported", "error"]) {
|
||||
expect(overview).toContain(`"${state}"`);
|
||||
}
|
||||
expect(overview).toContain("probeStates[key]");
|
||||
});
|
||||
|
||||
it("binds Session renew to connection and scope lifecycle and exposes lock indexes", () => {
|
||||
expect(sessions).toContain("connectionActive");
|
||||
expect(sessions).toContain("consulStore.generation");
|
||||
expect(sessions).toContain("currentScopeKey()");
|
||||
expect(sessions).toContain("clearTimeout(renewTimer)");
|
||||
expect(sessions).toContain("item.lockIndex");
|
||||
expect(sessions).toContain("session.NodeChecks");
|
||||
expect(sessions).toContain("session.ServiceChecks");
|
||||
expect(sessions).toContain("!isTtlSession(session)");
|
||||
expect(sessions).toContain("ConsulListPagination");
|
||||
expect(sessions).toContain("filteredSessions");
|
||||
expect(sessions).toContain("await inspectLockKey()");
|
||||
expect(sessions).toContain("expectedModifyIndex: lockInspection.value.modifyIndex");
|
||||
expect(sessions).toContain("heldKeys[session.ID].complete");
|
||||
expect(sessions).toContain("value.items.map(normalizeConsulSession)");
|
||||
expect(sessions).toContain("withUiTimeout(api.consulSessions(props.connectionId))");
|
||||
});
|
||||
|
||||
it("renders the complete Enterprise delete impact inventory", () => {
|
||||
for (const resource of ["kvKeys", "healthChecks", "sessions", "intentions", "peerings", "aclTokens", "aclPolicies", "aclRoles", "aclAuthMethods", "aclBindingRules"]) {
|
||||
expect(scope).toContain(`value.${resource}`);
|
||||
}
|
||||
expect(scope).toContain("impact.filteredByAcls");
|
||||
expect(scope).toContain("impact.unavailableResources");
|
||||
});
|
||||
|
||||
it("offers both Agent service maintenance transitions", () => {
|
||||
expect(services).toContain("maintenance(service.ID, true)");
|
||||
expect(services).toContain("maintenance(service.ID, false)");
|
||||
expect(services).toContain('t("consul.ui.enableMaintenance")');
|
||||
expect(services).toContain('t("consul.ui.disableMaintenance")');
|
||||
expect(services).toContain('v-if="canAgentWrite"');
|
||||
expect(services).toContain('t("consul.ui.agentWriteDisabledHint"');
|
||||
});
|
||||
|
||||
it("keeps Catalog browsing searchable and separates readable Catalog data from Agent actions", () => {
|
||||
expect(services).toContain('const browseFilter = ref("");');
|
||||
expect(services).toContain("const filteredServiceNames");
|
||||
expect(services).toContain("const filteredNodeNames");
|
||||
expect(services).toContain("const localServiceItems");
|
||||
expect(services).toContain("const filteredLocalServiceItems");
|
||||
expect(services).toContain("<Search class=");
|
||||
expect(services).toContain("selectedLocalService?.ID === service.ID");
|
||||
expect(services).toContain('<Button v-for="name in pagedServiceNames"');
|
||||
expect(services).not.toContain("services[name]?.join(', ')");
|
||||
expect(services).toContain('@click="selectService(name)"');
|
||||
expect(services).toContain('@click="selectNode(name)"');
|
||||
expect(services).toContain("Catalog browsing must remain responsive even when an Agent endpoint is slow or unavailable.");
|
||||
expect(services).toContain("void loadAgentData(current);");
|
||||
expect(services).toContain("t('consul.ui.searchAllServices')");
|
||||
expect(services).toContain("t('consul.ui.searchLocalServices')");
|
||||
});
|
||||
|
||||
it("loads Consul tools independently and exposes complete event and result details", () => {
|
||||
expect(tools).toContain('const activeTab = ref<ToolTab>("queries")');
|
||||
for (const loader of ["loadQueries", "loadEvents", "loadCoordinates"]) {
|
||||
expect(tools).toContain(`async function ${loader}()`);
|
||||
}
|
||||
for (const errorState of ["queryError", "eventError", "coordinateError"]) {
|
||||
expect(tools).toContain(`const ${errorState} = ref("")`);
|
||||
}
|
||||
expect(tools).not.toContain("Promise.allSettled");
|
||||
expect(tools).toContain('v-model="eventNodeFilter"');
|
||||
expect(tools).toContain('v-model="eventServiceFilter"');
|
||||
expect(tools).toContain('v-model="eventTagFilter"');
|
||||
expect(tools).toContain("queryResult.Nodes");
|
||||
expect(tools).toContain("item.Coord.Adjustment");
|
||||
expect(tools).toContain("item.Coord.Height");
|
||||
});
|
||||
|
||||
it("paginates every potentially large service collection before rendering", () => {
|
||||
for (const collection of ["pagedServiceNames", "pagedNodeNames", "pagedCatalogInstances", "pagedNodeServices", "pagedLocalServices"]) {
|
||||
expect(services).toContain(`const ${collection} = computed(() => paginateConsulItems(`);
|
||||
expect(services).toContain(`v-for="${collection === "pagedCatalogInstances" ? "item" : collection.includes("Names") ? "name" : "service"} in ${collection}"`);
|
||||
}
|
||||
expect(services).toContain("<ConsulListPagination");
|
||||
expect(services).toContain(':page-size="CONSUL_LIST_PAGE_SIZE"');
|
||||
});
|
||||
|
||||
it("gates Agent writes on an explicit target that matches the Agent node", () => {
|
||||
for (const source of [services, health]) {
|
||||
expect(source).toContain("config.agentTarget || config.agent_target");
|
||||
expect(source).toContain("consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node)");
|
||||
expect(source).toContain(':disabled="!canAgentWrite"');
|
||||
}
|
||||
});
|
||||
|
||||
it("supports exact-Key and prefix KV watches and exposes batch atomicity", () => {
|
||||
expect(keyBrowser).toContain('watchMode = ref<"key" | "prefix">("prefix")');
|
||||
expect(keyBrowser).toContain('prefix: watchMode.value === "prefix"');
|
||||
expect(keyBrowser).toContain(':on-watch-key="watchSelectedKey"');
|
||||
expect(keyBrowser).toContain("transferReport.atomic");
|
||||
expect(keyBrowser).toContain("deleteReport.atomic");
|
||||
expect(keyBrowser).toContain("row.opIndex");
|
||||
});
|
||||
|
||||
it("keeps the Key prefix search first and groups enhanced Consul KV actions after a divider", () => {
|
||||
expect(keyBrowser).toContain("<template #toolbar-trailing>");
|
||||
expect(keyBrowser).not.toContain('v-model="watchPrefix" class="h-7 w-52 text-xs"');
|
||||
expect(kvBrowser).toContain('<slot name="toolbar-trailing" />');
|
||||
expect(kvBrowser).toContain('class="mx-1 h-6 w-px shrink-0 bg-border"');
|
||||
});
|
||||
|
||||
it("keeps exact-Key watch start and stop beside the selected Key", () => {
|
||||
expect(keyBrowser).toContain(":watch-active-key=\"watchRunning && watchMode === 'key' ? watchPrefix : null\"");
|
||||
expect(keyBrowser).toContain('watchMode.value === "key" && watchPrefix.value === route.key');
|
||||
expect(keyBrowser).toContain("await stopWatch();\n return;");
|
||||
});
|
||||
|
||||
it("serializes search progress polling and invalidates stale pollers", () => {
|
||||
expect(keyBrowser).toContain("searchProgressSequence");
|
||||
expect(keyBrowser).toContain("searchProgressTimer = setTimeout");
|
||||
expect(keyBrowser).toContain("sequence === searchProgressSequence");
|
||||
expect(keyBrowser).not.toContain("searchProgressTimer = setInterval");
|
||||
});
|
||||
|
||||
it("uses a compact Session-protection card and a metadata grid for Consul KV details", () => {
|
||||
expect(kvBrowser).toContain('labels.sessionProtected || "Session protected"');
|
||||
expect(kvBrowser).toContain('labels.sessionProtectedHint || "Editing and deletion are disabled to protect this distributed lock."');
|
||||
expect(kvBrowser).toContain("copyText(selectedMetadata?.session)");
|
||||
expect(kvBrowser).toContain('<dl class="grid grid-cols-2 divide-x divide-y sm:grid-cols-3">');
|
||||
expect(kvBrowser).not.toContain('["Session", metadata?.session]');
|
||||
});
|
||||
|
||||
it("exports the current Consul full-text search result set", () => {
|
||||
expect(keyBrowser).toContain("async function exportSearchResults()");
|
||||
expect(keyBrowser).toContain('format: "dbx-consul-kv-search-results"');
|
||||
expect(keyBrowser).toContain('t("consul.tools.exportSearchResults")');
|
||||
});
|
||||
|
||||
it("carries full-text match metadata into the selected Consul KV detail", () => {
|
||||
expect(keyBrowser).toContain("const searchHighlight = ref<{");
|
||||
expect(keyBrowser).toContain("matchesKey: result.matchesKey");
|
||||
expect(keyBrowser).toContain(':search-highlight="searchHighlight"');
|
||||
});
|
||||
|
||||
it("offers the same server-backed prefix completion for export, migration, and prefix deletion", () => {
|
||||
expect(keyBrowser).toContain('type PrefixSuggestionTarget = "export" | "migration" | "delete"');
|
||||
expect(keyBrowser).toContain("const prefixSuggestionDebounceMs = 300;");
|
||||
expect(keyBrowser).toContain("function schedulePrefixSuggestions(target: PrefixSuggestionTarget, value: string)");
|
||||
expect(keyBrowser).toContain("schedulePrefixSuggestions(target, suggestion.key);");
|
||||
expect(keyBrowser).toContain('event.key !== "Enter" && event.key !== "Tab"');
|
||||
expect(keyBrowser).toContain("prefixSuggestionIndex.value >= 0 ? prefixSuggestionIndex.value : 0");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('export'");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('migration'");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('delete'");
|
||||
expect(keyBrowser).toContain("@keydown=\"onPrefixSuggestionKeydown('migration', $event)\"");
|
||||
expect(keyBrowser).toContain("@keydown=\"onPrefixSuggestionKeydown('delete', $event)\"");
|
||||
expect(keyBrowser).toContain('<PopoverContent align="start" side="bottom" :collision-padding="12"');
|
||||
expect(keyBrowser).toContain("max-h-[var(--reka-popover-content-available-height)]");
|
||||
});
|
||||
|
||||
it("marks the specific locked Key and Session in a prefix-delete preview", () => {
|
||||
expect(keyBrowser).toContain("'session' in row && row.session ? 'bg-destructive/5' : ''");
|
||||
expect(keyBrowser).toContain('v-if="\'session\' in row && row.session" variant="destructive"');
|
||||
expect(keyBrowser).toContain('t("consul.ui.sessionValue", { id: row.session })');
|
||||
});
|
||||
|
||||
it("shows the server-provided reason when a prefix-delete operation fails", () => {
|
||||
expect(keyBrowser).toContain("'message' in row && row.message");
|
||||
expect(keyBrowser).toContain(':title="row.message"');
|
||||
});
|
||||
|
||||
it("opens the safe prefix-delete preview from a Consul directory context menu", () => {
|
||||
expect(keyBrowser).toContain(':on-delete-prefix="openDeletePrefix"');
|
||||
expect(keyBrowser).toContain('function openDeletePrefix(prefix = "")');
|
||||
expect(keyBrowser).not.toContain('@click="openDeletePrefix"');
|
||||
expect(kvBrowser).toContain("if (nodeIsExpandable(node) && props.onDeletePrefix)");
|
||||
expect(kvBrowser).toContain("action: () => props.onDeletePrefix?.(nodePath(node))");
|
||||
});
|
||||
|
||||
it("preserves Exported Services CAS state and capability-gates advanced writes", () => {
|
||||
expect(mesh).toContain('consulMeshConfigList(props.connectionId, "exported-services")');
|
||||
expect(mesh).toContain("editingExported.value?.modifyIndex || 0");
|
||||
expect(tools).toContain("canWriteQueries");
|
||||
expect(tools).toContain("canFireEvents");
|
||||
expect(operator).toContain("keyringWriteVisible");
|
||||
expect(operator).toContain("props.capabilities?.operatorKeyring");
|
||||
});
|
||||
|
||||
it("keeps workspace actions fixed while only the tab strip scrolls horizontally", () => {
|
||||
expect(workspace).toContain("min-w-0 flex-1 overflow-x-auto overflow-y-hidden");
|
||||
expect(workspace).toContain("h-8 w-max min-w-max justify-start");
|
||||
expect(workspace).toContain('class="h-7 flex-none gap-1.5 px-3 text-xs"');
|
||||
expect(workspace).toContain("flex h-11 shrink-0 items-center gap-2 border-b px-3");
|
||||
expect(workspace).toContain('class="flex shrink-0 items-center gap-1"');
|
||||
});
|
||||
|
||||
it("starts with KV and keeps the cluster overview as a sidebar surface", () => {
|
||||
expect(workspace).toContain('const activeTab = ref<WorkspaceTab>("kv");');
|
||||
expect(workspace).not.toContain('<TabsTrigger value="overview"');
|
||||
expect(workspace).not.toContain('<TabsContent value="overview"');
|
||||
});
|
||||
|
||||
it("shows the real datacenter by default and gates advanced scope controls by capability", () => {
|
||||
expect(workspace).toContain("const resolvedDatacenter = computed(() => activeScope.value.datacenter || capabilities.value?.datacenter");
|
||||
expect(workspace).toContain('const enterpriseScopeAvailable = computed(() => capabilities.value?.namespaces === "supported" || capabilities.value?.partitions === "supported");');
|
||||
expect(workspace).toContain('v-if="enterpriseScopeAvailable"');
|
||||
expect(workspace).toContain('v-if="datacenterOptions.length > 1" v-model="scopeDraft.datacenter"');
|
||||
});
|
||||
|
||||
it("hides only disabled ACLs and Enterprise-only scope tabs", () => {
|
||||
expect(workspace).toContain('const aclVisible = computed(() => capabilities.value?.acl !== "disabled");');
|
||||
expect(workspace).toContain("const scopeTabVisible = computed(() => enterpriseScopeAvailable.value);");
|
||||
expect(workspace).toContain('v-if="aclVisible" value="acl"');
|
||||
expect(workspace).toContain('v-if="scopeTabVisible" value="scope"');
|
||||
expect(workspace).toContain('if (activeTab.value === "acl" && !aclVisible.value) activeTab.value = "kv";');
|
||||
});
|
||||
});
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listQueries: vi.fn(async () => []),
|
||||
updateConnection: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function mockWorkspaceChild(refresh = vi.fn(() => true)) {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
setup(_props: unknown, { expose }: { expose: (value: { refresh: () => boolean }) => void }) {
|
||||
expose({ refresh });
|
||||
return () => "mock-consul-child";
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/components/consul/ConsulKeyBrowser.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulServices.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulHealth.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulSessions.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulAcl.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulScope.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulMesh.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulOperator.vue", () => mockWorkspaceChild());
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
consulCapabilities: vi.fn(async () => ({
|
||||
acl: "supported",
|
||||
authMethods: "supported",
|
||||
bindingRules: "supported",
|
||||
templatedPolicies: "supported",
|
||||
namespaces: "supported",
|
||||
partitions: "supported",
|
||||
configEntries: "supported",
|
||||
intentions: "supported",
|
||||
peering: "supported",
|
||||
exportedServices: "supported",
|
||||
preparedQueries: "supported",
|
||||
events: "supported",
|
||||
coordinates: "supported",
|
||||
operatorAutopilot: "supported",
|
||||
operatorRaft: "supported",
|
||||
operatorKeyring: "supported",
|
||||
operatorUsage: "supported",
|
||||
operatorLicense: "supported",
|
||||
audit: "supported",
|
||||
datacenter: "dc1",
|
||||
})),
|
||||
consulCatalogDatacenters: vi.fn(async () => ["dc1", "dc2"]),
|
||||
consulPreparedQueryList: mocks.listQueries,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: () => ({
|
||||
id: "connection-1",
|
||||
external_config: { datacenter: "dc1", consulMeshVisible: true, consulOperatorVisible: true },
|
||||
}),
|
||||
updateConnection: mocks.updateConnection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/consulStore", () => ({
|
||||
useConsulStore: () => ({ bindConnection: vi.fn(), switchScope: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConsulWorkspace from "../ConsulWorkspace.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConsulWorkspace interactions", () => {
|
||||
it("renders a working datacenter selector when multiple datacenters are available", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulWorkspace, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const scopeButton = Array.from(host.querySelectorAll("button")).find((button) => button.title === "consul.ui.scope");
|
||||
expect(scopeButton).toBeTruthy();
|
||||
scopeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(document.body.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("dc1");
|
||||
});
|
||||
|
||||
it("routes the global refresh action to the active tools tab", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulWorkspace, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const toolsTab = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "consul.workspace.tools");
|
||||
expect(toolsTab).toBeTruthy();
|
||||
toolsTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
||||
await settle();
|
||||
expect(toolsTab!.getAttribute("aria-selected")).toBe("true");
|
||||
await vi.waitFor(() => expect(mocks.listQueries).toHaveBeenCalled());
|
||||
mocks.listQueries.mockClear();
|
||||
|
||||
const refreshButton = Array.from(host.querySelectorAll("button")).find((button) => button.title === "consul.ui.refresh");
|
||||
expect(refreshButton).toBeTruthy();
|
||||
refreshButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await vi.waitFor(() => expect(mocks.listQueries).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,7 @@ const showModified = ref(true);
|
||||
|
||||
let syncPlanRequestId = 0;
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(connection.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
const filteredSourceTables = computed(() => {
|
||||
|
||||
@@ -58,7 +58,7 @@ const targetSchemas = ref<string[]>([]);
|
||||
const sourceDbVersion = ref<string | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "etcd", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
|
||||
@@ -295,6 +295,7 @@ const labels = computed(() => ({
|
||||
editKey: t("etcd.editKey"),
|
||||
delete: t("etcd.delete"),
|
||||
deleteTitle: t("etcd.deleteTitle"),
|
||||
keyLabel: t("etcd.key"),
|
||||
keyPlaceholder: t("etcd.keyPlaceholder"),
|
||||
keyRequired: t("etcd.keyRequired"),
|
||||
saved: t("etcd.saved"),
|
||||
@@ -303,6 +304,9 @@ const labels = computed(() => ({
|
||||
rename: t("etcd.rename"),
|
||||
clone: t("etcd.clone"),
|
||||
copyKey: t("etcd.copyKey"),
|
||||
copy: t("grid.copy"),
|
||||
copied: t("grid.copied"),
|
||||
copyFailed: t("grid.copyFailed"),
|
||||
export: t("etcd.export"),
|
||||
history: t("etcd.history"),
|
||||
restore: t("etcd.restore"),
|
||||
@@ -1077,6 +1081,9 @@ defineExpose({ focusSearch, refresh });
|
||||
:read-only="readOnly"
|
||||
:enable-multi-select="true"
|
||||
:on-watch-key="openWatchForKey"
|
||||
export-format="dbx-etcd-bundle"
|
||||
export-file-extension=".dbx-etcd.json"
|
||||
export-fallback-name="etcd-key"
|
||||
:lease-options="leaseOptions"
|
||||
:on-lease-options-requested="refreshLeaseOptions"
|
||||
@refresh-requested="refreshTtlCapability"
|
||||
|
||||
@@ -103,7 +103,7 @@ const exportElapsedText = computed(() => {
|
||||
return formatDataTransferDuration((exportFinishedAt.value ?? currentTime.value) - exportStartedAt.value);
|
||||
});
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const canExport = computed(() => {
|
||||
const hasContent = includeStructure.value || includeData.value || includeObjects.value;
|
||||
|
||||
@@ -99,6 +99,7 @@ const assetIcons: Record<string, string> = {
|
||||
rocketmq: "rocketmq",
|
||||
rabbitmq: "rabbitmq",
|
||||
nacos: "nacos.png",
|
||||
consul: "consul",
|
||||
iris: "iris",
|
||||
influxdb: "influxdb",
|
||||
victoriametrics: "victoriametrics.png",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,19 @@ const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.ur
|
||||
const etcdBrowserSource = readFileSync(new URL("../../etcd/EtcdKeyBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("KvKeyBrowser node export", () => {
|
||||
it("keeps directory toggles out of the detail-loading path", () => {
|
||||
expect(browserSource).toContain('if (node.kind === "lazy") return node.hasValue === true;');
|
||||
expect(browserSource).toContain("if (props.enableNodeActions && nodeHasValue(node)) void loadSelectedKey(routeFromNode(node));");
|
||||
expect(browserSource).not.toContain("function nodeMayHaveValue");
|
||||
});
|
||||
|
||||
it("keeps an expanded virtual Consul directory open when its exact Key probe is absent", () => {
|
||||
expect(browserSource).toContain("const lazyNode = lazyTreeState.nodeByKey.get(key);");
|
||||
expect(browserSource).toContain("if (lazyNode?.hasChildren)");
|
||||
expect(browserSource).toContain("lazyNode.hasValue = false;");
|
||||
expect(browserSource).toContain("void refreshLazyParent(parentLazyKvPath(key, props.lazyPathStyle));");
|
||||
});
|
||||
|
||||
it("shows export for both value keys and virtual directories", () => {
|
||||
expect(browserSource).toContain("if (props.api.exportScope || nodeHasValue(node))");
|
||||
expect(browserSource).toContain('kind: nodeIsExpandable(node) ? "prefix" : "key"');
|
||||
|
||||
@@ -20,4 +20,12 @@ describe("KvKeyBrowser edit dialog layout", () => {
|
||||
expect(browserSource).toContain('<DialogFooter class="mx-0 mb-0 shrink-0 border-t px-6 py-5">');
|
||||
expect(browserSource).toContain('class="h-10 min-w-20" @click="showHistoryDialog = false"');
|
||||
});
|
||||
|
||||
it("keeps an accessible copy action in the upper-right corner of the value panel", () => {
|
||||
expect(browserSource).toContain('class="absolute right-2 top-2 z-10 h-8 w-8');
|
||||
expect(browserSource).toContain('@click="copySelectedValue"');
|
||||
expect(browserSource).toContain('<Check v-if="selectedValueCopied"');
|
||||
expect(browserSource).toContain('<Copy v-else class="h-4 w-4" />');
|
||||
expect(browserSource).toMatch(/<pre\s+data-native-clipboard/);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,48 @@ describe("KvKeyBrowser search and split layout", () => {
|
||||
expect(browserSource).not.toContain("filterKvKeysBySearch(result.keys, query)");
|
||||
});
|
||||
|
||||
it("focuses a matching Consul directory instead of nesting it beneath itself", () => {
|
||||
expect(browserSource).toContain('const focusedDirectory = props.lazyPathStyle === "relative" && rootPath && !rootPath.endsWith("/")');
|
||||
expect(browserSource).toContain("key.key === `${rootPath}/`");
|
||||
expect(browserSource).toContain("const childResult = await props.api.listPrefix(context.connectionId, focusedDirectory.key, pageSize, null, { recursive: false });");
|
||||
expect(browserSource).toContain("if (rootSummary?.hasValue !== true)");
|
||||
expect(browserSource).toContain("hasValue: true");
|
||||
});
|
||||
|
||||
it("offers debounced, server-backed prefix completion for lazy Consul trees", () => {
|
||||
expect(browserSource).toContain("const remoteKeySuggestions = ref<KvKeySummary[]>([]);");
|
||||
expect(browserSource).toContain("const keySuggestionDebounceMs = 300;");
|
||||
expect(browserSource).toContain("function scheduleRemoteKeySuggestions(value: string)");
|
||||
expect(browserSource).toContain("listPrefix(connectionId, query, 8, null, { recursive: false })");
|
||||
expect(browserSource).toContain("const candidates = props.lazyHierarchy ? [...lazyTreeState.nodeByKey.values(), ...remoteKeySuggestions.value] : keys.value;");
|
||||
expect(browserSource).toContain('autocomplete="off"');
|
||||
expect(browserSource).toContain('<Popover :open="showKeySuggestions">');
|
||||
expect(browserSource).toContain('<PopoverContent v-if="showKeySuggestions" id="kv-key-prefix-suggestions"');
|
||||
expect(browserSource).toContain('if (props.lazyHierarchy && suggestion.key.endsWith("/"))');
|
||||
expect(browserSource).toContain("scheduleRemoteKeySuggestions(suggestion.key);");
|
||||
expect(browserSource).toContain('event.key !== "Enter" && event.key !== "Tab"');
|
||||
});
|
||||
|
||||
it("drops stale lazy tree responses after a connection or root generation changes", () => {
|
||||
expect(browserSource).toContain("generation: ++keyLoadGeneration");
|
||||
expect(browserSource).toContain("function lazyLoadContextValid(context: LazyLoadContext)");
|
||||
expect(browserSource).toContain("context.connectionId === props.connectionId");
|
||||
expect(browserSource).toContain("lazyTreeState.nodeByKey.get(parentKey) !== node");
|
||||
expect(browserSource).toContain("keyLoadGeneration++;");
|
||||
});
|
||||
|
||||
it("highlights the matching search query only for the selected search result", () => {
|
||||
expect(browserSource).toContain("interface KvSearchHighlight");
|
||||
expect(browserSource).toMatch(/const activeSearchHighlight = computed\(\(\) => \(?props\.searchHighlight\?\.key === selectedKey\.value/);
|
||||
expect(browserSource).toContain("function highlightText(value: string, enabled: boolean)");
|
||||
expect(browserSource).toContain("selectedValueHighlightSegments");
|
||||
});
|
||||
|
||||
it("does not select Key text while opening its context menu", () => {
|
||||
expect(browserSource).toContain("select-none items-center");
|
||||
expect(browserSource).toContain("@mousedown.right.prevent");
|
||||
});
|
||||
|
||||
it("uses a persisted draggable split between the Key tree and details", () => {
|
||||
expect(browserSource).toContain('<Splitpanes class="kv-browser-splitpanes min-h-0 flex-1" @resized="handleKvBrowserSplitResized">');
|
||||
expect(browserSource).toContain('<Pane :size="kvBrowserSplitSize" min-size="20" max-size="70">');
|
||||
|
||||
@@ -525,7 +525,8 @@ function tabMenuIcon(tab: QueryTab) {
|
||||
if (tab.externalSqlFileMissing) return AlertTriangle;
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "hbase") return Table2;
|
||||
if (tab.mode === "vector") return TableProperties;
|
||||
if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound;
|
||||
if (tab.mode === "etcd" || tab.mode === "zookeeper" || tab.mode === "consul") return KeyRound;
|
||||
if (tab.mode === "consul-overview") return Gauge;
|
||||
if (tab.mode === "etcd-dashboard") return Gauge;
|
||||
if (tab.mode === "etcd-access-control") return ShieldCheck;
|
||||
if (tab.mode === "nacos") return Network;
|
||||
@@ -671,7 +672,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<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" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'consul-overview'" 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" />
|
||||
@@ -867,7 +869,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<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" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'consul-overview'" 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" />
|
||||
|
||||
@@ -53,6 +53,8 @@ const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/Etcd
|
||||
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 ConsulOverview = defineAsyncComponent(() => import("@/components/consul/ConsulOverview.vue"));
|
||||
const ConsulWorkspace = defineAsyncComponent(() => import("@/components/consul/ConsulWorkspace.vue"));
|
||||
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
|
||||
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
|
||||
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
|
||||
@@ -242,6 +244,8 @@ function openDataGridExtractorConfiguration() {
|
||||
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const etcdDashboardRef = ref<{ refresh?: () => boolean }>();
|
||||
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const consulOverviewRef = ref<{ refresh?: () => boolean }>();
|
||||
const consulWorkspaceRef = ref<SearchableBrowserHandle>();
|
||||
const objectBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const activeTableMeta = computed(() => props.activeTab.tableMeta);
|
||||
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
|
||||
@@ -778,6 +782,7 @@ function focusSearch(): boolean {
|
||||
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "query") return queryEditorRef.value?.openSearch() ?? false;
|
||||
return dataGridRef.value?.focusSearch() ?? false;
|
||||
@@ -795,6 +800,8 @@ function refreshData(): boolean {
|
||||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "etcd-dashboard") return etcdDashboardRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "consul-overview") return consulOverviewRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.refresh?.() ?? false;
|
||||
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
|
||||
if (canReloadUnavailableDataTab(props.activeTab)) {
|
||||
emit("reload");
|
||||
@@ -1941,6 +1948,19 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'consul-overview'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<ConsulOverview ref="consulOverviewRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Consul management workspace -->
|
||||
<template v-else-if="activeTab.mode === 'consul'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<ConsulWorkspace ref="consulWorkspaceRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
|
||||
@@ -109,6 +109,7 @@ const supportsExplain = computed(() => {
|
||||
dbType !== "chromadb" &&
|
||||
dbType !== "etcd" &&
|
||||
dbType !== "zookeeper" &&
|
||||
dbType !== "consul" &&
|
||||
dbType !== "mq" &&
|
||||
dbType !== "nacos" &&
|
||||
dbType !== "victoriametrics"
|
||||
@@ -490,6 +491,7 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
activeConnection?.db_type !== 'weaviate' &&
|
||||
activeConnection?.db_type !== 'chromadb' &&
|
||||
activeConnection?.db_type !== 'zookeeper' &&
|
||||
activeConnection?.db_type !== 'consul' &&
|
||||
!isSingleDb
|
||||
"
|
||||
class="flex items-center gap-1"
|
||||
|
||||
@@ -252,7 +252,7 @@ const isRootListPartial = computed(() => sidebarFilterGuards.value.isRootListPar
|
||||
|
||||
const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
||||
connection: ["connection"],
|
||||
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "mongo-db"],
|
||||
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "consul-root", "mongo-db"],
|
||||
schema: ["schema"],
|
||||
table: ["table", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"],
|
||||
view: ["view"],
|
||||
@@ -1094,7 +1094,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 === "etcd-access-control" || 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" || target.type === "consul-root") return;
|
||||
const connId = target.connectionId;
|
||||
if (!connId) return;
|
||||
|
||||
@@ -1132,7 +1132,7 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
|
||||
}
|
||||
}
|
||||
|
||||
if (config.db_type === "mq" || config.db_type === "nacos") return;
|
||||
if (config.db_type === "mq" || config.db_type === "nacos" || config.db_type === "consul") return;
|
||||
if (!("database" in target) || !target.database) return;
|
||||
|
||||
// Find the database node
|
||||
|
||||
@@ -94,7 +94,7 @@ import {
|
||||
usesTreeSchemaMode,
|
||||
isSingleDatabase,
|
||||
} from "@/lib/database/databaseCapabilities";
|
||||
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
@@ -538,8 +538,31 @@ function isGroupLabel(node: TreeNode): boolean {
|
||||
return groupTypes.has(node.type);
|
||||
}
|
||||
|
||||
async function openDirectNavigationNode(node: TreeNode) {
|
||||
if (!node.connectionId) return;
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const connectionName = connectionStore.getConfig(node.connectionId)?.name || "Consul";
|
||||
if (node.type === "consul-root") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:keys`, "consul");
|
||||
refreshActiveKvBrowserAfterOpen("consul", node.connectionId);
|
||||
} else if (node.type === "consul-overview") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:${t("consul.ui.overview")}`, "consul-overview");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
const node = activeNode.value;
|
||||
if (isDirectNavigationTreeNode(node.type)) {
|
||||
try {
|
||||
await openDirectNavigationNode(node);
|
||||
} catch (e: any) {
|
||||
const errMsg = e?.message || String(e);
|
||||
if (errMsg.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
openDriverStoreForInstallError(errMsg, node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.isLoading) {
|
||||
if (node.isExpanded) {
|
||||
node.isExpanded = false;
|
||||
@@ -607,6 +630,8 @@ async function toggle() {
|
||||
await connectionStore.loadEtcdRoot(node.connectionId);
|
||||
} else if (config?.db_type === "zookeeper") {
|
||||
await connectionStore.loadZooKeeperRoot(node.connectionId);
|
||||
} else if (config?.db_type === "consul") {
|
||||
await connectionStore.loadConsulRoot(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
@@ -779,7 +804,7 @@ function runRowClickAction(clickDetail: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshActiveKvBrowserAfterOpen(mode: "etcd" | "zookeeper", connectionId: string) {
|
||||
function refreshActiveKvBrowserAfterOpen(mode: "etcd" | "zookeeper" | "consul", connectionId: string) {
|
||||
void nextTick(() => {
|
||||
window.dispatchEvent(new CustomEvent("dbx-refresh-active-kv-browser", { detail: { mode, connectionId } }));
|
||||
});
|
||||
@@ -3385,7 +3410,7 @@ const canOpenSqlFileExecution = computed(() => {
|
||||
const canExportAllDatabases = computed(() => {
|
||||
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
|
||||
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
|
||||
return !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(dbType || "");
|
||||
return !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
|
||||
});
|
||||
|
||||
const canOpenDiagram = computed(() => {
|
||||
@@ -4328,7 +4353,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control" || node.type === "zookeeper-root") {
|
||||
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control" || node.type === "zookeeper-root" || node.type === "consul-root" || node.type === "consul-overview") {
|
||||
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import { useDragSort } from "@/composables/useDragSort";
|
||||
import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime";
|
||||
import { treeNodePinKey } from "@/lib/app/pinnedItems";
|
||||
import { isTreeGroupNodeType } from "@/lib/sidebar/treeNodeGroup";
|
||||
import { shouldActivateTreeNodeOnSingleClick } from "@/lib/sidebar/treeNodeClick";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -265,7 +266,10 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
case "etcd-access-control":
|
||||
return { icon: ShieldCheck, colorClass: "text-sky-500" };
|
||||
case "zookeeper-root":
|
||||
case "consul-root":
|
||||
return { icon: Database, colorClass: "text-blue-500" };
|
||||
case "consul-overview":
|
||||
return { icon: Gauge, colorClass: "text-blue-500" };
|
||||
case "mongo-db":
|
||||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "mongo-gridfs":
|
||||
@@ -1069,7 +1073,7 @@ function onClick(event: MouseEvent) {
|
||||
}
|
||||
selectSingleTreeNode(props.node);
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
if (settingsStore.editorSettings.sidebarActivation === "double" && props.node.type !== "load-more") return;
|
||||
if (!shouldActivateTreeNodeOnSingleClick(props.node.type, settingsStore.editorSettings.sidebarActivation) && props.node.type !== "load-more") return;
|
||||
treeRuntime.handleRowClick(props.node, event.detail);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ function resetPerFileState() {
|
||||
currentFileName.value = "";
|
||||
}
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const QUICK_OPEN_MAX_RESULTS = 200;
|
||||
const INITIAL_SQL_LIBRARY_LIMIT = 20;
|
||||
const INITIAL_SQL_FILE_LIMIT = 20;
|
||||
|
||||
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos"]);
|
||||
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
|
||||
export interface QuickOpenItem {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import en from "../locales/en";
|
||||
import es from "../locales/es";
|
||||
import it_ from "../locales/it";
|
||||
import ja from "../locales/ja";
|
||||
import ko from "../locales/ko";
|
||||
import ptBR from "../locales/pt-BR";
|
||||
import zhCN from "../locales/zh-CN";
|
||||
import zhTW from "../locales/zh-TW";
|
||||
|
||||
const locales: Array<[string, Record<string, unknown>]> = [
|
||||
["en", en],
|
||||
["es", es],
|
||||
["it", it_],
|
||||
["ja", ja],
|
||||
["ko", ko],
|
||||
["pt-BR", ptBR],
|
||||
["zh-CN", zhCN],
|
||||
["zh-TW", zhTW],
|
||||
];
|
||||
|
||||
function leafKeys(value: unknown, prefix = ""): string[] {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return [prefix];
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) => leafKeys(child, prefix ? `${prefix}.${key}` : key));
|
||||
}
|
||||
|
||||
describe("Consul advanced UI locale parity", () => {
|
||||
const expected = leafKeys((en as { consul: { ui: unknown } }).consul.ui).sort();
|
||||
const expectedTools = leafKeys((en as { consul: { tools: unknown } }).consul.tools).sort();
|
||||
|
||||
it.each(locales)("%s exposes the full consul.ui key set", (_name, locale) => {
|
||||
const ui = (locale as { consul: { ui: unknown } }).consul.ui;
|
||||
expect(leafKeys(ui).sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(locales)("%s exposes the full consul.tools key set", (_name, locale) => {
|
||||
const tools = (locale as { consul: { tools: unknown } }).consul.tools;
|
||||
expect(leafKeys(tools).sort()).toEqual(expectedTools);
|
||||
});
|
||||
|
||||
it("uses Chinese labels for the Consul KV workflow", () => {
|
||||
const consul = (zhCN as { consul: { tools: Record<string, unknown>; keyLabel: string; valueContent: string; format: string } }).consul;
|
||||
const tools = consul.tools;
|
||||
expect(tools.search).toBe("全文搜索");
|
||||
expect(tools.exportPrefix).toBe("导出");
|
||||
expect(tools.import).toBe("导入");
|
||||
expect(tools.migrate).toBe("同步");
|
||||
expect(tools.deletePrefix).toBe("前缀删除");
|
||||
expect(consul.keyLabel).toBe("键(Key)");
|
||||
expect(consul.valueContent).toBe("值");
|
||||
expect(consul.format).toBe("格式");
|
||||
});
|
||||
|
||||
it("localizes Consul service, Session, and capability labels", () => {
|
||||
const ui = (zhCN as { consul: { ui: Record<string, unknown> } }).consul.ui;
|
||||
expect(ui.serviceKind).toBe("服务类型(ServiceKind)");
|
||||
expect(ui.serviceWeights).toBe("通过 / 警告权重");
|
||||
expect(ui.behaviorRelease).toBe("释放锁(release)");
|
||||
expect(ui.behaviorDelete).toBe("删除 Key(delete)");
|
||||
expect(ui.capabilityDisabled).toBe("未启用");
|
||||
});
|
||||
|
||||
it.each(["ConsulOverview", "ConsulServices", "ConsulHealth", "ConsulSessions", "ConsulAcl", "ConsulScope", "ConsulMesh", "ConsulTools", "ConsulOperator", "ConsulWorkspace"])("%s uses the i18n composable", (component) => {
|
||||
const source = readFileSync(new URL(`../../components/consul/${component}.vue`, import.meta.url), "utf8");
|
||||
expect(source).toContain('import { useI18n } from "vue-i18n"');
|
||||
expect(source).toContain("useI18n()");
|
||||
});
|
||||
|
||||
it("keeps Consul user-facing labels out of component templates", () => {
|
||||
const components = ["ConsulServices", "ConsulHealth", "ConsulSessions", "ConsulMesh", "ConsulKeyBrowser", "ConsulOperator"];
|
||||
const forbidden = [
|
||||
"Catalog nodes",
|
||||
"No visible services",
|
||||
"No visible nodes",
|
||||
"Agent target:",
|
||||
"Watch paused",
|
||||
"Watching index",
|
||||
"Pause watch",
|
||||
"Start watch",
|
||||
"HTTP Agent registrations belong",
|
||||
"Catalog instances",
|
||||
"Node services",
|
||||
"Local Agent services",
|
||||
"Enable maintenance",
|
||||
"Disable maintenance",
|
||||
"No matching health checks",
|
||||
"Service instances",
|
||||
"Local Agent checks",
|
||||
'placeholder="Watch prefix"',
|
||||
">Key</Badge>",
|
||||
">Value</Badge>",
|
||||
'placeholder="UPDATE AUTOPILOT"',
|
||||
'placeholder="CHANGE KEYRING"',
|
||||
'placeholder="UPDATE LICENSE"',
|
||||
">ServiceKind<",
|
||||
">Passing / Warning<",
|
||||
">Node ID<",
|
||||
">TaggedAddresses<",
|
||||
">EnableTagOverride<",
|
||||
">Behavior<",
|
||||
">CreateIndex<",
|
||||
">ModifyIndex<",
|
||||
"Session {{ row.session }}",
|
||||
];
|
||||
|
||||
for (const component of components) {
|
||||
const source = readFileSync(new URL(`../../components/consul/${component}.vue`, import.meta.url), "utf8");
|
||||
const template = source.slice(source.indexOf("<template>"));
|
||||
for (const literal of forbidden) expect(template, `${component} contains ${literal}`).not.toContain(literal);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
// Shared fallback for Consul's advanced workspaces. Individual locales may override these keys.
|
||||
export const consulUiMessages = {
|
||||
ui: {
|
||||
unknown: "Unknown",
|
||||
peeringPending: "Pending",
|
||||
peeringEstablishing: "Establishing",
|
||||
peeringActive: "Active",
|
||||
peeringFailing: "Failing",
|
||||
peeringDeleting: "Deleting",
|
||||
peeringTerminated: "Terminated",
|
||||
refresh: "Refresh",
|
||||
cancel: "Cancel",
|
||||
create: "Create",
|
||||
update: "Update",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
apply: "Apply",
|
||||
close: "Close",
|
||||
hide: "Hide",
|
||||
reveal: "Reveal",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
copyFailed: "Copy failed",
|
||||
copyOnce: "Copy once",
|
||||
pause: "Pause",
|
||||
watchAction: "Watch",
|
||||
watchPrefix: "Watch prefix",
|
||||
watchModePrefix: "Prefix",
|
||||
watchModeKey: "Exact key",
|
||||
sessionProtected: "Protected by Session",
|
||||
sessionProtectedHint: "Editing and deletion are disabled to protect this distributed lock.",
|
||||
atomicTransaction: "Atomic transaction",
|
||||
nonAtomicBatches: "Non-atomic across {count} batches",
|
||||
batchOperation: "Batch {batch} · OpIndex {op}",
|
||||
operationTotals: "Succeeded {succeeded} · Conflicted {conflicted} · Skipped {skipped} · Failed {failed}",
|
||||
deleteOperationTotals: "Succeeded {succeeded} · Conflicted {conflicted} · Failed {failed}",
|
||||
clientSideMigration: "DBX reads from the source and writes to the target. This is not native Consul cross-Datacenter replication.",
|
||||
bundleFileType: "DBX Consul KV Bundle",
|
||||
sourceKeyMissingModifyIndex: "Source key has no ModifyIndex",
|
||||
transactionRejected: "Consul rejected the transaction",
|
||||
overview: "Cluster overview",
|
||||
capabilities: "Capabilities",
|
||||
probeLoading: "Loading",
|
||||
probeReady: "Ready",
|
||||
probeForbidden: "Forbidden",
|
||||
probeUnsupported: "Unsupported",
|
||||
probeError: "Error",
|
||||
autopilotHealth: "Autopilot health",
|
||||
datacenter: "Datacenter",
|
||||
agentNode: "Agent node",
|
||||
agentAddress: "Agent address",
|
||||
version: "Version",
|
||||
mode: "Mode",
|
||||
server: "Server",
|
||||
client: "Client",
|
||||
leader: "Leader",
|
||||
raftPeers: "Raft peers",
|
||||
membersAlive: "Members alive",
|
||||
members: "Members",
|
||||
node: "Node",
|
||||
address: "Address",
|
||||
status: "Status",
|
||||
alive: "alive",
|
||||
leaving: "leaving",
|
||||
failed: "failed",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
pageStatus: "{page} / {pages} · {total} total",
|
||||
searchAllServices: "Search all services",
|
||||
searchAllNodes: "Search all nodes",
|
||||
searchLocalServices: "Search local Agent services",
|
||||
query: "Query",
|
||||
searchHealthChecks: "Search check name, ID, node, service, output, or notes",
|
||||
allCheckTypes: "All check types",
|
||||
clearFilters: "Clear filters",
|
||||
healthResultCount: "{filtered} of {total} checks",
|
||||
services: "Services",
|
||||
nodes: "Nodes",
|
||||
service: "Service",
|
||||
catalogServices: "Catalog services",
|
||||
catalogNodes: "Catalog nodes",
|
||||
noVisibleServices: "No visible services",
|
||||
noVisibleNodes: "No visible nodes",
|
||||
noMatchingServices: "No matching services",
|
||||
noMatchingNodes: "No matching nodes",
|
||||
agentTarget: "Agent target: {target}",
|
||||
agentTargetVerifying: "Verifying Agent target",
|
||||
unknownWritesDisabled: "unknown - writes disabled",
|
||||
targetAgent: "the target Agent",
|
||||
agentWriteReady: "Writable",
|
||||
agentReadOnly: "Read-only",
|
||||
agentWriteDisabledHint: "No matching Agent target is configured, so maintenance and deregistration are disabled. Detected Agent: {target}.",
|
||||
register: "Register",
|
||||
registrationHint: "HTTP Agent registrations belong to the target Agent runtime and do not modify its persistent configuration files.",
|
||||
name: "Name",
|
||||
id: "ID",
|
||||
port: "Port",
|
||||
tags: "Tags (comma-separated)",
|
||||
registerOn: "Register on {node}",
|
||||
catalogInstances: "Catalog instances",
|
||||
nodeServices: "Node services",
|
||||
serviceId: "Service ID",
|
||||
localAgentServices: "Local Agent services",
|
||||
enableMaintenance: "Enable maintenance",
|
||||
disableMaintenance: "Disable maintenance",
|
||||
deregister: "Deregister",
|
||||
deregisterService: "Deregister local service {id} from {node}?",
|
||||
maintenanceReason: "DBX user request",
|
||||
serviceDetails: "{service} details",
|
||||
kind: "Kind",
|
||||
serviceKind: "ServiceKind",
|
||||
standard: "standard",
|
||||
metadata: "Metadata",
|
||||
nodeMeta: "Node meta",
|
||||
serviceMeta: "Service meta",
|
||||
nodeId: "Node ID",
|
||||
serviceWeights: "Passing / Warning",
|
||||
taggedAddresses: "TaggedAddresses",
|
||||
enableTagOverride: "EnableTagOverride",
|
||||
agent: "Agent",
|
||||
watch: "Watch",
|
||||
writeOperation: "Write operation",
|
||||
localServiceDetails: "Local service details",
|
||||
agentIdentityRequestTimedOut: "Loading Agent identity timed out.",
|
||||
localServicesRequestTimedOut: "Loading local Agent services timed out.",
|
||||
localChecksRequestTimedOut: "Loading local Agent checks timed out.",
|
||||
healthChecks: "Health checks",
|
||||
state: "State",
|
||||
nodeName: "Node name",
|
||||
allStates: "All states",
|
||||
passing: "Passing",
|
||||
warning: "Warning",
|
||||
critical: "Critical",
|
||||
watchPaused: "Watch paused",
|
||||
watchingIndex: "Watching index {index}",
|
||||
pauseWatch: "Pause watch",
|
||||
startWatch: "Start watch",
|
||||
checkRegistrationHint: "Agent checks are runtime registrations on {node} and are not persisted to Agent configuration files.",
|
||||
type: "Type",
|
||||
ttl: "TTL",
|
||||
http: "HTTP",
|
||||
tcp: "TCP",
|
||||
grpc: "gRPC",
|
||||
docker: "Docker",
|
||||
script: "Script",
|
||||
arguments: "Arguments",
|
||||
containerId: "Container ID",
|
||||
endpoint: "Endpoint",
|
||||
interval: "Interval",
|
||||
timeout: "Timeout",
|
||||
commandArguments: "Command arguments",
|
||||
clusterHealth: "Cluster health",
|
||||
nodeHealth: "Node health: {node}",
|
||||
serviceHealth: "Service health: {service}",
|
||||
noMatchingHealthChecks: "No matching health checks",
|
||||
serviceInstances: "Service instances",
|
||||
localAgentChecks: "Local Agent checks",
|
||||
maintenance: "maintenance",
|
||||
ttlPass: "TTL pass",
|
||||
ttlWarn: "TTL warn",
|
||||
ttlFail: "TTL fail",
|
||||
deregisterCheck: "Deregister check {id} from {node}?",
|
||||
checkDetails: "{check} details",
|
||||
checkId: "Check ID",
|
||||
notes: "Notes",
|
||||
output: "Output",
|
||||
definition: "Definition",
|
||||
indexes: "CreateIndex / ModifyIndex",
|
||||
sessionsAndLocks: "Sessions and locks",
|
||||
sessions: "Sessions",
|
||||
sessionDescription: "A Session is a lease-like contract used by Consul KV locks. TTL Sessions require renewal; Sessions without TTL remain until explicitly destroyed or invalidated by their checks.",
|
||||
createSession: "Create Session",
|
||||
searchSessions: "Search Session name, ID, node, or check",
|
||||
sessionResultCount: "Showing {filtered} of {total} Sessions",
|
||||
noMatchingSessions: "No matching Sessions",
|
||||
allSessionTypes: "All Session types",
|
||||
ttlSessions: "TTL Sessions",
|
||||
persistentSessions: "No-TTL Sessions",
|
||||
allBehaviors: "All behaviors",
|
||||
aclFilteredSessions: "The Session list was filtered by ACLs",
|
||||
sessionRequestTimedOut: "Loading Sessions timed out. Check the Consul connection and try again.",
|
||||
complete: "Complete",
|
||||
lock: "Lock",
|
||||
lockManagement: "KV lock management",
|
||||
lockIndex: "LockIndex {index}",
|
||||
lockDelay: "Lock delay",
|
||||
lockDelayHint: "0s–60s; delays reacquisition after invalidation.",
|
||||
nodeOptional: "Node (optional)",
|
||||
behavior: "Behavior",
|
||||
behaviorRelease: "release",
|
||||
behaviorDelete: "delete",
|
||||
behaviorHint: "release keeps held keys; delete removes them when the Session expires.",
|
||||
releaseLocks: "Release locks",
|
||||
deleteKeys: "Delete keys",
|
||||
deleteBehaviorWarning: "Behavior=delete removes every key held by this Session when it is invalidated. Continue?",
|
||||
checks: "Checks",
|
||||
nodeChecks: "Node checks",
|
||||
serviceChecks: "Service checks",
|
||||
nodeCheckShort: "Node",
|
||||
serviceCheckShort: "Service",
|
||||
serviceChecksPlaceholder: "service:api:health@namespace-a",
|
||||
serviceChecksHint: "Comma-separated check IDs; use ID@namespace for a scoped check.",
|
||||
lockHint: "Inspect the key, select a Session, then acquire or release the advisory lock.",
|
||||
key: "Key",
|
||||
inspectKey: "Inspect key",
|
||||
keyExists: "Key exists",
|
||||
keyNotFound: "Key does not exist",
|
||||
keyUnlocked: "Key is not currently held",
|
||||
keyHeldBy: "Held by Session {session}",
|
||||
sessionValue: "Session {id}",
|
||||
modifyIndexValue: "ModifyIndex {index}",
|
||||
createIndex: "CreateIndex",
|
||||
modifyIndexLabel: "ModifyIndex",
|
||||
selectSession: "Select a Session",
|
||||
sessionId: "Session ID",
|
||||
expectedModifyIndex: "Expected ModifyIndex (0 for new key)",
|
||||
value: "Value",
|
||||
releaseMatchingSession: "Release",
|
||||
acquire: "Acquire",
|
||||
unnamedSession: "Unnamed Session",
|
||||
heldKeys: "Held keys",
|
||||
heldKeysIncomplete: "Results are incomplete or ACL-filtered",
|
||||
stop: "Stop",
|
||||
renewNow: "Renew now",
|
||||
autoRenew: "Auto renew",
|
||||
autoRenewPageHint: "Auto renewal runs only while this connection and page remain active.",
|
||||
noRenewRequired: "No renewal needed",
|
||||
noTtl: "No TTL",
|
||||
ttlOptional: "TTL (optional)",
|
||||
ttlRangeHint: "Leave empty for a persistent Session, or use 10s–24h.",
|
||||
invalidSessionTtl: "TTL must be empty or a valid duration from 10s to 24h.",
|
||||
invalidLockDelay: "Lock delay must be a valid duration from 0s to 60s.",
|
||||
destroy: "Destroy",
|
||||
noHeldKeys: "No held keys",
|
||||
noVisibleSessions: "No visible Sessions",
|
||||
destroySession: "Destroy Session {id}? Locks using this Session may be released or deleted by its Behavior.",
|
||||
destroySessionImpact: "Destroy Session {id}? Behavior: {behavior}. Held keys ({count}): {keys}",
|
||||
sessionImpactIncomplete: "Session impact is incomplete or ACL-filtered. Destruction is blocked.",
|
||||
sessionDetails: "TTL {ttl} · {behavior} · checks {checks}",
|
||||
tokens: "Tokens",
|
||||
policies: "Policies",
|
||||
roles: "Roles",
|
||||
authMethods: "Auth Methods",
|
||||
bindingRules: "Binding Rules",
|
||||
templatedPolicies: "Templated Policies",
|
||||
capability: "Capability: {status}",
|
||||
capabilitySupported: "Supported",
|
||||
capabilityUnsupported: "Unsupported",
|
||||
capabilityDisabled: "Disabled",
|
||||
capabilityForbidden: "No permission",
|
||||
capabilityUnknown: "Unknown",
|
||||
loading: "Loading...",
|
||||
viewOrEdit: "View or edit",
|
||||
cloneToken: "Clone token",
|
||||
deleteAndInspectReferences: "Delete and inspect references",
|
||||
noResources: "No resources",
|
||||
editResource: "Edit {kind}",
|
||||
createResource: "Create {kind}",
|
||||
knownImpact: "Known impact: {tokens} tokens, {roles} roles, {bindingRules} binding rules",
|
||||
incomplete: "(incomplete)",
|
||||
reviewAclChanges: "Review ACL changes",
|
||||
deleteResource: "Delete {label}",
|
||||
tokenReferences: "Token references: {tokens} · Role references: {roles} · Binding rule references: {bindingRules}",
|
||||
impactBlocked: "Impact is incomplete or ACL-filtered. Deletion is blocked.",
|
||||
cloneTokenDescription: "Description for cloned token",
|
||||
scope: "Consul scope",
|
||||
adminPartition: "Admin Partition",
|
||||
namespace: "Namespace",
|
||||
default: "default",
|
||||
applyScope: "Apply scope",
|
||||
enterpriseCapability: "Enterprise capability: {status}. Lifecycle actions are unavailable.",
|
||||
namespaces: "Namespaces",
|
||||
lifecycleImpact: "Scoped resource impact",
|
||||
inspectImpactAndDelete: "Inspect impact and delete",
|
||||
impactAclFiltered: "Consul reports ACL-filtered results; deletion is blocked.",
|
||||
impactUnavailable: "Could not enumerate: {resources}",
|
||||
impactResource: {
|
||||
services: "Services",
|
||||
nodes: "Nodes",
|
||||
kvKeys: "KV keys",
|
||||
healthChecks: "Health checks",
|
||||
sessions: "Sessions",
|
||||
configEntries: "Config entries",
|
||||
intentions: "Intentions",
|
||||
peerings: "Peerings",
|
||||
namespaces: "Namespaces",
|
||||
aclTokens: "ACL tokens",
|
||||
aclPolicies: "ACL policies",
|
||||
aclRoles: "ACL roles",
|
||||
aclAuthMethods: "ACL auth methods",
|
||||
aclBindingRules: "ACL binding rules",
|
||||
},
|
||||
configEntries: "Config Entries",
|
||||
intentions: "Intentions",
|
||||
discoveryChain: "Discovery Chain",
|
||||
peering: "Peering",
|
||||
exportedServices: "Exported Services",
|
||||
configEntryKind: "Config entry kind",
|
||||
loadKind: "Load kind",
|
||||
newJson: "New JSON",
|
||||
modifyIndex: "ModifyIndex {index}",
|
||||
rawJson: "Raw JSON",
|
||||
newIntention: "New intention",
|
||||
sourceService: "Source service",
|
||||
destinationService: "Destination service",
|
||||
check: "Check",
|
||||
matchSource: "Match source",
|
||||
matchDestination: "Match destination",
|
||||
allowed: "Allowed",
|
||||
denied: "Denied",
|
||||
matchedIntentions: "Matched {count} intention(s)",
|
||||
details: "Details",
|
||||
serviceName: "Service name",
|
||||
compile: "Compile",
|
||||
defaultProtocol: "default protocol",
|
||||
configuredDatacenter: "configured datacenter",
|
||||
start: "Start",
|
||||
next: "Next: {nodes}",
|
||||
terminalTarget: "Terminal / target",
|
||||
upstreamTargets: "Upstream targets ({count})",
|
||||
peerName: "Peer name",
|
||||
peeringToken: "Peering token to establish",
|
||||
generateToken: "Generate token",
|
||||
establish: "Establish",
|
||||
editExportedServices: "Edit exported-services entry",
|
||||
serviceMeshConfigEntry: "Service Mesh config entry",
|
||||
applyWithCas: "Apply with CAS",
|
||||
reviewMeshChanges: "Review Service Mesh changes",
|
||||
deleteConfig: "Delete {kind}/{name}?",
|
||||
deleteIntention: "Delete intention {id}?",
|
||||
deleteIntentionExact: "Delete intention {source} -> {destination}?",
|
||||
deletePeering: "Delete peering {name}?",
|
||||
permissions: "{count} permissions",
|
||||
actionRule: "action rule",
|
||||
tools: "Consul tools",
|
||||
preparedQueries: "Prepared queries",
|
||||
events: "Events",
|
||||
coordinates: "Network coordinates",
|
||||
queryName: "Query name",
|
||||
targetService: "Target service",
|
||||
targetServiceValue: "Target service: {service}",
|
||||
cancelEdit: "Cancel edit",
|
||||
execute: "Execute",
|
||||
explain: "Explain",
|
||||
eventName: "Event name",
|
||||
payload: "Payload",
|
||||
fire: "Fire",
|
||||
deletePreparedQuery: "Delete prepared query {id}?",
|
||||
nodesDatacenter: "{count} nodes · {datacenter}",
|
||||
coordinateError: "{segment} · error {error}",
|
||||
preparedQueriesDescription: "Reusable service-discovery queries stored in Consul. Execute resolves matching healthy service instances; Explain shows the effective query. The simple editor changes only the name and target service, preserving advanced fields already stored by Consul.",
|
||||
eventsDescription: "Lightweight, best-effort messages distributed through Consul gossip. Events are transient diagnostics or notifications, not a durable message queue.",
|
||||
coordinatesDescription: "Read-only network coordinates estimated by Consul to compare network proximity. They are not geographic locations.",
|
||||
noPreparedQueries: "No prepared queries",
|
||||
noEvents: "No recent events",
|
||||
noCoordinates: "No network coordinates",
|
||||
queryExecutionResult: "Execution result · {count} instances · {datacenter}",
|
||||
noQueryNodes: "No service instances matched this query",
|
||||
queryExplanation: "Effective query",
|
||||
eventNodeFilter: "Node regex (optional)",
|
||||
eventServiceFilter: "Service regex (optional)",
|
||||
eventTagFilter: "Tag regex (optional)",
|
||||
eventPayloadBytes: "Payload {count} / 100 bytes",
|
||||
segment: "Segment",
|
||||
error: "Error",
|
||||
adjustment: "Adjustment",
|
||||
height: "Height",
|
||||
operatorReadOnly: "Operator (read-only)",
|
||||
snapshot: "Snapshot",
|
||||
snapshotRestore: "Snapshot restore",
|
||||
targetDatacenter: "Target Datacenter",
|
||||
restoreSnapshot: "Restore snapshot",
|
||||
restoreSnapshotConfirmation: "RESTORE SNAPSHOT {datacenter}",
|
||||
autopilotUpdate: "Autopilot update",
|
||||
cleanupDeadServers: "Cleanup dead servers",
|
||||
updateAutopilot: "Update Autopilot",
|
||||
updateAutopilotConfirmation: "UPDATE AUTOPILOT",
|
||||
raftOperations: "Raft operations",
|
||||
serverId: "Server ID",
|
||||
peerAddress: "Peer address (remove only)",
|
||||
reenterIdAddress: "Re-enter ID or address",
|
||||
transferLeader: "Transfer leader",
|
||||
removePeer: "Remove peer",
|
||||
gossipKeyring: "Gossip keyring",
|
||||
operation: "Operation",
|
||||
install: "Install",
|
||||
use: "Use",
|
||||
remove: "Remove",
|
||||
gossipKey: "Gossip key",
|
||||
changeKeyring: "Change keyring",
|
||||
changeKeyringConfirmation: "CHANGE KEYRING",
|
||||
licenseUpdate: "License update",
|
||||
license: "License",
|
||||
updateLicense: "Update license",
|
||||
updateLicenseConfirmation: "UPDATE LICENSE",
|
||||
},
|
||||
tools: {
|
||||
search: "Search",
|
||||
searchTitle: "Search Consul KV",
|
||||
searchQuery: "Text to find",
|
||||
prefixOptional: "Prefix (optional)",
|
||||
searchAll: "Keys and values",
|
||||
searchKeys: "Keys only",
|
||||
searchValues: "Values only",
|
||||
caseSensitive: "Case-sensitive",
|
||||
scanned: "Scanned {count} keys",
|
||||
searchLimited: "Results are limited to protect the connection.",
|
||||
noSearchResults: "No matching keys",
|
||||
searchSummary: "{matched} matches from {scanned} scanned keys",
|
||||
exportSearchResults: "Export results",
|
||||
searchResultsFileType: "Consul KV search results",
|
||||
searchResultsExported: "Exported {count} search results",
|
||||
exportPrefix: "Export prefix",
|
||||
prefix: "Prefix",
|
||||
emptyPrefixAll: "Leave empty for all accessible keys",
|
||||
exportLimitHint: "Exports include the current scope and preserve the key metadata needed for safe import.",
|
||||
exported: "Exported {count} keys",
|
||||
import: "Import",
|
||||
importTitle: "Import Consul KV bundle",
|
||||
invalidBundle: "This file is not a valid DBX Consul KV bundle",
|
||||
importApplied: "Imported {count} keys",
|
||||
migrate: "Migrate",
|
||||
migrateTitle: "Migrate Consul KV",
|
||||
sourcePrefix: "Source prefix",
|
||||
targetConnection: "Target connection",
|
||||
selectTarget: "Select a target connection",
|
||||
targetReadOnly: "The target connection is read-only.",
|
||||
conflictPolicy: "Conflict policy",
|
||||
policyAbort: "Abort on conflict",
|
||||
policySkip: "Skip conflicts",
|
||||
policyCas: "Use CAS",
|
||||
bundleSummary: "{count} keys · Datacenter {dc} · Namespace {ns} · Partition {partition}",
|
||||
creates: "Create {count}",
|
||||
updates: "Update {count}",
|
||||
unchanged: "Unchanged {count}",
|
||||
skipped: "Skipped {count}",
|
||||
conflicts: "Conflicts {count}",
|
||||
loadPreview: "Load preview",
|
||||
loadingPreview: "Loading preview...",
|
||||
apply: "Apply",
|
||||
preview: "Preview",
|
||||
deletePrefix: "Delete prefix",
|
||||
deletePrefixTitle: "Delete Consul KV prefix",
|
||||
deletePrefixPlaceholder: "Prefix to delete",
|
||||
deletePrefixWarning: "This operation deletes every visible key under the prefix. Review the preview before confirming.",
|
||||
candidates: "Candidates {count}",
|
||||
lockedCount: "Locked {count}",
|
||||
filtered: "filtered",
|
||||
complete: "complete",
|
||||
confirmDeletePrefix: "Delete confirmed keys",
|
||||
prefixDeleted: "Deleted {count} keys",
|
||||
operation: { create: "Create", update: "Update", unchanged: "Unchanged", skipped: "Skipped", conflict: "Conflict", locked: "Locked", succeeded: "Succeeded", conflicted: "Conflicted", failed: "Failed" },
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,517 @@
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
export const consul = {
|
||||
...consulUiMessages,
|
||||
ui: {
|
||||
...consulUiMessages.ui,
|
||||
atomicTransaction: "单事务原子执行",
|
||||
nonAtomicBatches: "跨 {count} 个批次不具备整体原子性",
|
||||
batchOperation: "批次 {batch} · OpIndex {op}",
|
||||
operationTotals: "成功 {succeeded} · 冲突 {conflicted} · 跳过 {skipped} · 失败 {failed}",
|
||||
deleteOperationTotals: "成功 {succeeded} · 冲突 {conflicted} · 失败 {failed}",
|
||||
clientSideMigration: "DBX 会从源连接读取后写入目标连接;这不是 Consul 原生跨 Datacenter 复制。",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败",
|
||||
sessionProtected: "受 Session 保护",
|
||||
sessionProtectedHint: "为避免破坏分布式锁,编辑与删除已禁用。",
|
||||
previousPage: "上一页",
|
||||
nextPage: "下一页",
|
||||
pageStatus: "第 {page} / {pages} 页 · 共 {total} 条",
|
||||
searchAllServices: "全局搜索服务",
|
||||
searchAllNodes: "全局搜索节点",
|
||||
searchLocalServices: "搜索本地 Agent 服务",
|
||||
query: "查询",
|
||||
searchHealthChecks: "搜索检查名称、ID、节点、服务、输出或备注",
|
||||
allCheckTypes: "全部检查类型",
|
||||
clearFilters: "清除过滤",
|
||||
healthResultCount: "显示 {filtered} / {total} 个检查",
|
||||
unknown: "未知",
|
||||
peeringPending: "等待中",
|
||||
peeringEstablishing: "正在建立",
|
||||
peeringActive: "已连接",
|
||||
peeringFailing: "连接异常",
|
||||
peeringDeleting: "正在删除",
|
||||
peeringTerminated: "已终止",
|
||||
refresh: "刷新",
|
||||
cancel: "取消",
|
||||
create: "创建",
|
||||
update: "更新",
|
||||
save: "保存",
|
||||
delete: "删除",
|
||||
edit: "编辑",
|
||||
apply: "应用",
|
||||
close: "关闭",
|
||||
pause: "暂停",
|
||||
watchAction: "监视",
|
||||
watchPrefix: "监视前缀",
|
||||
watchModePrefix: "前缀",
|
||||
watchModeKey: "精确 Key",
|
||||
bundleFileType: "DBX Consul KV 数据包",
|
||||
sourceKeyMissingModifyIndex: "源 Key 没有 ModifyIndex",
|
||||
transactionRejected: "Consul 拒绝了该事务",
|
||||
overview: "集群概览",
|
||||
capabilities: "能力探测",
|
||||
probeLoading: "加载中",
|
||||
probeReady: "可用",
|
||||
probeForbidden: "无权限",
|
||||
probeUnsupported: "不支持",
|
||||
probeError: "错误",
|
||||
autopilotHealth: "Autopilot 健康状态",
|
||||
services: "服务",
|
||||
nodes: "节点",
|
||||
service: "服务",
|
||||
node: "节点",
|
||||
catalogServices: "Catalog 服务",
|
||||
catalogNodes: "Catalog 节点",
|
||||
noVisibleServices: "没有可见服务",
|
||||
noVisibleNodes: "没有可见节点",
|
||||
noMatchingServices: "没有匹配的服务",
|
||||
noMatchingNodes: "没有匹配的节点",
|
||||
agentTarget: "Agent 目标:{target}",
|
||||
agentTargetVerifying: "正在校验 Agent 目标",
|
||||
unknownWritesDisabled: "未知,写操作已禁用",
|
||||
targetAgent: "目标 Agent",
|
||||
agentWriteReady: "可操作",
|
||||
agentReadOnly: "只读",
|
||||
agentWriteDisabledHint: "未配置匹配的 Agent 目标,因此维护和注销操作已禁用。当前检测到:{target}。",
|
||||
register: "注册",
|
||||
registrationHint: "HTTP Agent 注册仅作用于目标 Agent 的运行时,不会修改其持久化配置文件。",
|
||||
name: "名称",
|
||||
port: "端口",
|
||||
tags: "标签(逗号分隔)",
|
||||
registerOn: "注册到 {node}",
|
||||
catalogInstances: "Catalog 实例",
|
||||
nodeServices: "节点服务",
|
||||
serviceId: "服务 ID",
|
||||
localAgentServices: "本地 Agent 服务",
|
||||
enableMaintenance: "启用维护模式",
|
||||
disableMaintenance: "停用维护模式",
|
||||
deregister: "注销",
|
||||
deregisterService: "从 {node} 注销本地服务 {id}?",
|
||||
maintenanceReason: "DBX 用户请求",
|
||||
serviceDetails: "{service} 详情",
|
||||
kind: "类型",
|
||||
serviceKind: "服务类型(ServiceKind)",
|
||||
standard: "标准",
|
||||
metadata: "元数据",
|
||||
nodeMeta: "节点元数据",
|
||||
serviceMeta: "服务元数据",
|
||||
nodeId: "节点 ID",
|
||||
serviceWeights: "通过 / 警告权重",
|
||||
taggedAddresses: "标记地址(TaggedAddresses)",
|
||||
enableTagOverride: "允许覆盖标签(EnableTagOverride)",
|
||||
agent: "Agent",
|
||||
writeOperation: "写操作",
|
||||
localServiceDetails: "本地服务详情",
|
||||
agentIdentityRequestTimedOut: "加载 Agent 身份信息超时。",
|
||||
localServicesRequestTimedOut: "加载本地 Agent 服务超时。",
|
||||
localChecksRequestTimedOut: "加载本地 Agent 检查超时。",
|
||||
healthChecks: "健康检查",
|
||||
state: "状态",
|
||||
nodeName: "节点名称",
|
||||
allStates: "全部状态",
|
||||
passing: "通过",
|
||||
warning: "警告",
|
||||
critical: "严重",
|
||||
watchingIndex: "正在监视索引 {index}",
|
||||
checkRegistrationHint: "Agent 检查仅注册在 {node} 的运行时中,不会持久化到 Agent 配置文件。",
|
||||
type: "类型",
|
||||
arguments: "参数",
|
||||
containerId: "Container ID",
|
||||
endpoint: "端点",
|
||||
interval: "间隔",
|
||||
timeout: "超时",
|
||||
commandArguments: "命令参数",
|
||||
clusterHealth: "集群健康状态",
|
||||
nodeHealth: "节点健康状态:{node}",
|
||||
serviceHealth: "服务健康状态:{service}",
|
||||
noMatchingHealthChecks: "没有匹配的健康检查",
|
||||
serviceInstances: "服务实例",
|
||||
localAgentChecks: "本地 Agent 检查",
|
||||
maintenance: "维护中",
|
||||
ttlPass: "TTL 通过",
|
||||
ttlWarn: "TTL 警告",
|
||||
ttlFail: "TTL 失败",
|
||||
deregisterCheck: "从 {node} 注销检查 {id}?",
|
||||
checkDetails: "{check} 详情",
|
||||
checkId: "检查 ID",
|
||||
notes: "备注",
|
||||
output: "输出",
|
||||
definition: "定义",
|
||||
indexes: "CreateIndex / ModifyIndex",
|
||||
sessionsAndLocks: "会话和锁",
|
||||
scope: "Consul 作用域",
|
||||
tools: "Consul 工具",
|
||||
loading: "加载中...",
|
||||
noResources: "没有资源",
|
||||
capability: "能力:{status}",
|
||||
capabilitySupported: "支持",
|
||||
capabilityUnsupported: "不支持",
|
||||
capabilityDisabled: "未启用",
|
||||
capabilityForbidden: "无权限",
|
||||
capabilityUnknown: "未知",
|
||||
applyScope: "应用作用域",
|
||||
impactAclFiltered: "Consul 返回了 ACL 过滤结果,删除已阻止。",
|
||||
impactUnavailable: "无法完整枚举:{resources}",
|
||||
impactResource: {
|
||||
services: "服务",
|
||||
nodes: "节点",
|
||||
kvKeys: "KV Key",
|
||||
healthChecks: "健康检查",
|
||||
sessions: "Session",
|
||||
configEntries: "配置条目",
|
||||
intentions: "Intention",
|
||||
peerings: "Peering",
|
||||
namespaces: "Namespace",
|
||||
aclTokens: "ACL Token",
|
||||
aclPolicies: "ACL Policy",
|
||||
aclRoles: "ACL Role",
|
||||
aclAuthMethods: "ACL Auth Method",
|
||||
aclBindingRules: "ACL Binding Rule",
|
||||
},
|
||||
reveal: "显示",
|
||||
copyOnce: "复制一次",
|
||||
datacenter: "数据中心",
|
||||
agentNode: "Agent 节点",
|
||||
agentAddress: "Agent 地址",
|
||||
version: "版本",
|
||||
mode: "模式",
|
||||
server: "服务端",
|
||||
client: "客户端",
|
||||
leader: "Leader",
|
||||
raftPeers: "Raft 对等节点",
|
||||
membersAlive: "存活成员",
|
||||
members: "成员",
|
||||
address: "地址",
|
||||
status: "状态",
|
||||
alive: "存活",
|
||||
leaving: "离开中",
|
||||
failed: "故障",
|
||||
tokens: "令牌",
|
||||
policies: "策略",
|
||||
roles: "角色",
|
||||
authMethods: "认证方式",
|
||||
bindingRules: "绑定规则",
|
||||
templatedPolicies: "模板策略",
|
||||
viewOrEdit: "查看或编辑",
|
||||
cloneToken: "克隆令牌",
|
||||
deleteAndInspectReferences: "检查引用后删除",
|
||||
editResource: "编辑 {kind}",
|
||||
createResource: "新建 {kind}",
|
||||
knownImpact: "已知影响:{tokens} 个令牌、{roles} 个角色、{bindingRules} 条绑定规则",
|
||||
incomplete: "(不完整)",
|
||||
reviewAclChanges: "确认 ACL 变更",
|
||||
deleteResource: "删除 {label}",
|
||||
tokenReferences: "令牌引用:{tokens} · 角色引用:{roles} · 绑定规则引用:{bindingRules}",
|
||||
impactBlocked: "影响范围不完整或已被 ACL 过滤,禁止删除。",
|
||||
cloneTokenDescription: "克隆令牌的说明",
|
||||
sessions: "会话",
|
||||
sessionDescription: "会话是 Consul 分布式锁使用的租约型契约。带 TTL 的会话需要续租;无 TTL 会话会持续存在,直到主动销毁或关联检查失效。",
|
||||
createSession: "创建会话",
|
||||
searchSessions: "搜索会话名称、ID、节点或检查项",
|
||||
sessionResultCount: "显示 {filtered} / {total} 个会话",
|
||||
noMatchingSessions: "没有匹配的会话",
|
||||
allSessionTypes: "全部会话类型",
|
||||
ttlSessions: "TTL 会话",
|
||||
persistentSessions: "无 TTL 会话",
|
||||
allBehaviors: "全部失效行为",
|
||||
aclFilteredSessions: "会话列表已被 ACL 过滤",
|
||||
sessionRequestTimedOut: "加载会话超时,请检查 Consul 连接后重试。",
|
||||
complete: "完整",
|
||||
lock: "锁",
|
||||
lockManagement: "KV 锁管理",
|
||||
lockIndex: "锁索引 {index}",
|
||||
lockDelay: "锁延迟",
|
||||
lockDelayHint: "范围 0s–60s;会话失效后延迟其他会话重新获取锁。",
|
||||
nodeOptional: "节点(可选)",
|
||||
behavior: "失效行为",
|
||||
behaviorRelease: "释放锁(release)",
|
||||
behaviorDelete: "删除 Key(delete)",
|
||||
behaviorHint: "release 会保留 Key 并释放锁;delete 会在会话失效时删除所持 Key。",
|
||||
releaseLocks: "释放锁(release)",
|
||||
deleteKeys: "删除 Key(delete)",
|
||||
deleteBehaviorWarning: "delete 行为会在会话失效时删除它持有的所有 Key。确定继续吗?",
|
||||
checks: "关联检查",
|
||||
nodeChecks: "节点检查",
|
||||
serviceChecks: "服务检查",
|
||||
nodeCheckShort: "节点",
|
||||
serviceCheckShort: "服务",
|
||||
serviceChecksPlaceholder: "service:api:health@namespace-a",
|
||||
serviceChecksHint: "多个检查 ID 用逗号分隔;作用域检查可写为 ID@命名空间。",
|
||||
lockHint: "先检查 Key,再选择会话执行获取或释放。",
|
||||
key: "Key",
|
||||
inspectKey: "检查 Key",
|
||||
keyExists: "Key 已存在",
|
||||
keyNotFound: "Key 不存在",
|
||||
keyUnlocked: "Key 当前未被会话持有",
|
||||
keyHeldBy: "由会话 {session} 持有",
|
||||
sessionValue: "会话 {id}",
|
||||
modifyIndexValue: "ModifyIndex {index}",
|
||||
createIndex: "CreateIndex",
|
||||
modifyIndexLabel: "ModifyIndex",
|
||||
selectSession: "选择会话",
|
||||
sessionId: "会话 ID",
|
||||
expectedModifyIndex: "期望的 ModifyIndex(新 Key 为 0)",
|
||||
value: "值",
|
||||
releaseMatchingSession: "释放",
|
||||
acquire: "获取",
|
||||
unnamedSession: "未命名会话",
|
||||
heldKeys: "持有的 Key",
|
||||
heldKeysIncomplete: "结果不完整或已被 ACL 过滤",
|
||||
stop: "停止",
|
||||
renewNow: "立即续租",
|
||||
autoRenew: "自动续租",
|
||||
autoRenewPageHint: "仅在当前连接和页面保持活跃时自动续租。",
|
||||
noRenewRequired: "无需续租",
|
||||
noTtl: "无 TTL",
|
||||
ttlOptional: "TTL(可选)",
|
||||
ttlRangeHint: "留空表示持久会话;填写时范围为 10s–24h。",
|
||||
invalidSessionTtl: "TTL 必须留空,或填写 10s–24h 的有效时长。",
|
||||
invalidLockDelay: "锁延迟必须填写 0s–60s 的有效时长。",
|
||||
destroy: "销毁",
|
||||
noHeldKeys: "没有持有的 Key",
|
||||
noVisibleSessions: "没有可见会话",
|
||||
destroySession: "销毁会话 {id}?该会话所持锁会按其行为释放或删除。",
|
||||
destroySessionImpact: "销毁会话 {id}?行为:{behavior}。持有的 Key({count}):{keys}",
|
||||
sessionImpactIncomplete: "会话影响范围不完整或已被 ACL 过滤,已阻止销毁。",
|
||||
sessionDetails: "TTL {ttl} · {behavior} · 检查项 {checks}",
|
||||
adminPartition: "管理分区",
|
||||
namespace: "命名空间",
|
||||
default: "默认",
|
||||
enterpriseCapability: "Enterprise 能力:{status}。生命周期操作不可用。",
|
||||
namespaces: "命名空间",
|
||||
lifecycleImpact: "作用域资源影响",
|
||||
inspectImpactAndDelete: "检查影响并删除",
|
||||
configEntries: "配置条目",
|
||||
intentions: "服务意图",
|
||||
discoveryChain: "服务发现链",
|
||||
peering: "对等连接",
|
||||
exportedServices: "导出服务",
|
||||
configEntryKind: "配置条目类型",
|
||||
loadKind: "加载类型",
|
||||
newJson: "新建 JSON",
|
||||
modifyIndex: "ModifyIndex {index}",
|
||||
rawJson: "原始 JSON",
|
||||
newIntention: "新建服务意图",
|
||||
sourceService: "源服务",
|
||||
destinationService: "目标服务",
|
||||
check: "检查",
|
||||
matchSource: "匹配源服务",
|
||||
matchDestination: "匹配目标服务",
|
||||
allowed: "允许",
|
||||
denied: "拒绝",
|
||||
matchedIntentions: "匹配到 {count} 条服务意图",
|
||||
details: "详情",
|
||||
serviceName: "服务名称",
|
||||
compile: "编译",
|
||||
defaultProtocol: "默认协议",
|
||||
configuredDatacenter: "配置的数据中心",
|
||||
start: "起点",
|
||||
next: "下一跳:{nodes}",
|
||||
terminalTarget: "终点 / 目标",
|
||||
upstreamTargets: "上游目标({count})",
|
||||
peerName: "对等连接名称",
|
||||
peeringToken: "用于建立连接的对等令牌",
|
||||
generateToken: "生成令牌",
|
||||
establish: "建立",
|
||||
editExportedServices: "编辑 exported-services 条目",
|
||||
serviceMeshConfigEntry: "服务网格配置条目",
|
||||
applyWithCas: "使用 CAS 应用",
|
||||
reviewMeshChanges: "确认服务网格变更",
|
||||
deleteConfig: "删除 {kind}/{name}?",
|
||||
deleteIntention: "删除服务意图 {id}?",
|
||||
deleteIntentionExact: "删除服务意图 {source} -> {destination}?",
|
||||
deletePeering: "删除对等连接 {name}?",
|
||||
permissions: "{count} 条权限",
|
||||
actionRule: "动作规则",
|
||||
preparedQueries: "预设查询",
|
||||
events: "事件",
|
||||
coordinates: "网络坐标",
|
||||
queryName: "查询名称",
|
||||
targetService: "目标服务",
|
||||
targetServiceValue: "目标服务:{service}",
|
||||
cancelEdit: "取消编辑",
|
||||
execute: "执行",
|
||||
explain: "说明",
|
||||
eventName: "事件名称",
|
||||
payload: "负载内容",
|
||||
fire: "触发",
|
||||
deletePreparedQuery: "删除预设查询 {id}?",
|
||||
nodesDatacenter: "{count} 个节点 · {datacenter}",
|
||||
coordinateError: "{segment} · 错误 {error}",
|
||||
preparedQueriesDescription: "保存在 Consul 中、可重复使用的服务发现查询。执行会解析符合条件的健康服务实例,说明会展示最终生效的查询。简易编辑器只修改名称和目标服务,并保留 Consul 中已有的高级字段。",
|
||||
eventsDescription: "通过 Consul Gossip 分发的轻量、尽力而为消息,适合临时诊断和通知;它不是持久化消息队列。",
|
||||
coordinatesDescription: "Consul 估算的只读网络坐标,用于比较节点间的网络距离;它不是地理位置坐标。",
|
||||
noPreparedQueries: "暂无预设查询",
|
||||
noEvents: "暂无近期事件",
|
||||
noCoordinates: "暂无网络坐标",
|
||||
queryExecutionResult: "执行结果 · {count} 个实例 · {datacenter}",
|
||||
noQueryNodes: "没有服务实例符合该查询",
|
||||
queryExplanation: "生效的查询",
|
||||
eventNodeFilter: "节点正则(可选)",
|
||||
eventServiceFilter: "服务正则(可选)",
|
||||
eventTagFilter: "标签正则(可选)",
|
||||
eventPayloadBytes: "负载 {count} / 100 字节",
|
||||
segment: "网络分段",
|
||||
error: "误差",
|
||||
adjustment: "校正值",
|
||||
height: "高度",
|
||||
operatorReadOnly: "运维(只读)",
|
||||
snapshot: "快照",
|
||||
snapshotRestore: "恢复快照",
|
||||
targetDatacenter: "目标数据中心",
|
||||
restoreSnapshot: "恢复快照",
|
||||
autopilotUpdate: "Autopilot 更新",
|
||||
cleanupDeadServers: "清理失效服务端",
|
||||
updateAutopilot: "更新 Autopilot",
|
||||
raftOperations: "Raft 操作",
|
||||
serverId: "服务端 ID",
|
||||
peerAddress: "对等节点地址(仅移除时需要)",
|
||||
reenterIdAddress: "重新输入 ID 或地址",
|
||||
transferLeader: "转移 Leader",
|
||||
removePeer: "移除对等节点",
|
||||
gossipKeyring: "Gossip 密钥环",
|
||||
operation: "操作",
|
||||
install: "安装",
|
||||
use: "使用",
|
||||
remove: "移除",
|
||||
gossipKey: "Gossip 密钥",
|
||||
changeKeyring: "修改密钥环",
|
||||
licenseUpdate: "许可证更新",
|
||||
license: "许可证",
|
||||
updateLicense: "更新许可证",
|
||||
watch: "监视",
|
||||
watchPaused: "监视已暂停",
|
||||
pauseWatch: "暂停监视",
|
||||
startWatch: "启动监视",
|
||||
},
|
||||
tools: {
|
||||
search: "全文搜索",
|
||||
searchTitle: "搜索 Consul KV",
|
||||
searchQuery: "输入要查找的内容",
|
||||
prefixOptional: "前缀(可选)",
|
||||
searchAll: "Key 和值",
|
||||
searchKeys: "仅 Key",
|
||||
searchValues: "仅值",
|
||||
caseSensitive: "区分大小写",
|
||||
scanned: "已扫描 {count} 个 Key",
|
||||
searchLimited: "为保护连接,本次结果已受限。",
|
||||
noSearchResults: "没有匹配的 Key",
|
||||
searchSummary: "已扫描 {scanned} 个 Key,匹配 {matched} 个",
|
||||
exportSearchResults: "导出结果",
|
||||
searchResultsFileType: "Consul KV 搜索结果",
|
||||
searchResultsExported: "已导出 {count} 条搜索结果",
|
||||
exportPrefix: "导出",
|
||||
prefix: "前缀",
|
||||
emptyPrefixAll: "留空则导出全部可访问 Key",
|
||||
exportLimitHint: "导出会保留当前作用域及安全导入所需的 Key 元数据。",
|
||||
exported: "已导出 {count} 个 Key",
|
||||
import: "导入",
|
||||
importTitle: "导入 Consul KV 数据包",
|
||||
invalidBundle: "该文件不是有效的 DBX Consul KV 数据包",
|
||||
importApplied: "已导入 {count} 个 Key",
|
||||
migrate: "同步",
|
||||
migrateTitle: "迁移 Consul KV",
|
||||
sourcePrefix: "源前缀",
|
||||
targetConnection: "目标连接",
|
||||
selectTarget: "选择目标连接",
|
||||
targetReadOnly: "目标连接为只读,无法写入。",
|
||||
conflictPolicy: "冲突策略",
|
||||
policyAbort: "遇到冲突即终止",
|
||||
policySkip: "跳过冲突项",
|
||||
policyCas: "使用 CAS",
|
||||
bundleSummary: "{count} 个 Key · 数据中心 {dc} · 命名空间 {ns} · 管理分区 {partition}",
|
||||
creates: "新建 {count}",
|
||||
updates: "更新 {count}",
|
||||
unchanged: "未变更 {count}",
|
||||
skipped: "跳过 {count}",
|
||||
conflicts: "冲突 {count}",
|
||||
loadPreview: "加载预览",
|
||||
loadingPreview: "正在加载预览...",
|
||||
apply: "应用",
|
||||
preview: "预览",
|
||||
deletePrefix: "前缀删除",
|
||||
deletePrefixTitle: "删除 Consul KV 前缀",
|
||||
deletePrefixPlaceholder: "输入要删除的前缀",
|
||||
deletePrefixWarning: "此操作会删除该前缀下所有可见 Key。请先核对预览,再执行确认。",
|
||||
candidates: "候选项 {count}",
|
||||
lockedCount: "已锁定 {count}",
|
||||
filtered: "已过滤",
|
||||
complete: "完整",
|
||||
confirmDeletePrefix: "删除已确认的 Key",
|
||||
prefixDeleted: "已删除 {count} 个 Key",
|
||||
operation: { create: "新建", update: "更新", unchanged: "未变更", skipped: "已跳过", conflict: "冲突", locked: "已锁定", succeeded: "成功", conflicted: "冲突", failed: "失败" },
|
||||
},
|
||||
prefixPlaceholder: "Key 前缀,例如 app/ 或 services/",
|
||||
newKey: "新建 Key",
|
||||
loadingKeys: "正在加载 Key...",
|
||||
empty: "未找到 Key",
|
||||
loadMore: "加载更多",
|
||||
selectKey: "选择一个 Key 查看其值",
|
||||
loadingValue: "正在加载值...",
|
||||
notFound: "未找到 Key",
|
||||
edit: "编辑",
|
||||
editKey: "编辑 Key",
|
||||
delete: "删除",
|
||||
deleteTitle: "删除 Consul KV Key",
|
||||
keyLabel: "键(Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key 不能为空",
|
||||
rootReadonly: "根前缀不能修改,请输入具体 Key 路径。",
|
||||
saved: "Key 已保存",
|
||||
deleted: "Key 已删除",
|
||||
base64Readonly: "请在 Base64 模式下编辑二进制值。",
|
||||
rename: "重命名",
|
||||
clone: "克隆",
|
||||
copyKey: "复制 Key",
|
||||
export: "导出",
|
||||
value: "值",
|
||||
valueContent: "值",
|
||||
format: "格式",
|
||||
metadata: "KV 元数据",
|
||||
prettyJson: "格式化",
|
||||
invalidJson: "JSON 格式无效",
|
||||
summarySize: "大小",
|
||||
conflict: "该 Key 自加载后已发生变化,请刷新后重试。",
|
||||
keyAlreadyExists: "同名 Key 已存在,请使用其他名称或编辑现有 Key。",
|
||||
aclFiltered: "部分 Consul KV Key 已被 ACL 策略过滤。",
|
||||
locked: "该 Key 正由 Consul Session 持有,不能编辑或删除。",
|
||||
valueTooLarge: "Consul KV 值不能超过 512 KiB。",
|
||||
watchKey: "监视",
|
||||
stopWatching: "停止",
|
||||
workspace: {
|
||||
overview: "概览",
|
||||
kv: "键值存储",
|
||||
services: "服务",
|
||||
health: "健康检查",
|
||||
sessions: "会话",
|
||||
scope: "作用域",
|
||||
mesh: "服务网格",
|
||||
tools: "工具",
|
||||
operator: "运维",
|
||||
refresh: "刷新能力信息",
|
||||
loading: "正在加载 Consul 能力...",
|
||||
agentIdentity: "Agent 身份",
|
||||
capabilities: "能力状态",
|
||||
version: "版本",
|
||||
edition: "版本类型",
|
||||
datacenter: "数据中心",
|
||||
node: "Agent 节点",
|
||||
mode: "Agent 模式",
|
||||
agent: "Agent API",
|
||||
acl: "访问控制",
|
||||
namespaces: "命名空间",
|
||||
partitions: "管理分区",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "支持",
|
||||
unsupported: "不支持",
|
||||
forbidden: "无权限",
|
||||
unknown: "未知",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,80 @@
|
||||
import docs from "./docs/en";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "Key",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "Rename",
|
||||
clone: "Clone",
|
||||
copyKey: "Copy Key",
|
||||
export: "Export",
|
||||
value: "Value",
|
||||
valueContent: "Value",
|
||||
format: "Format",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "Watch",
|
||||
stopWatching: "Stop",
|
||||
workspace: {
|
||||
overview: "Overview",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "Refresh capabilities",
|
||||
loading: "Loading Consul capabilities...",
|
||||
agentIdentity: "Agent identity",
|
||||
capabilities: "Capabilities",
|
||||
version: "Version",
|
||||
edition: "Edition",
|
||||
datacenter: "Datacenter",
|
||||
node: "Agent node",
|
||||
mode: "Agent mode",
|
||||
agent: "Agent API",
|
||||
acl: "ACL",
|
||||
namespaces: "Namespaces",
|
||||
partitions: "Admin Partitions",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "Supported",
|
||||
unsupported: "Unsupported",
|
||||
forbidden: "No permission",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
app: {
|
||||
@@ -458,6 +534,34 @@ export default {
|
||||
etcdClientCertBrowse: "Choose client certificate",
|
||||
etcdClientKeyBrowse: "Choose client private key",
|
||||
etcdClientCertPairRequired: "Client certificate and private key must be provided together.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "Connect String",
|
||||
zookeeperConnectStringHint: "Comma or line separated host:port entries, with an optional shared /chroot. Leave blank to use the host and port above.",
|
||||
zookeeperClusterInputHint: "For a ZooKeeper ensemble, enter all nodes either in the URL (for example zookeeper://zk-1:2181,zk-2:2181/app) or in Connect String below.",
|
||||
@@ -1140,6 +1244,7 @@ export default {
|
||||
etcdDashboard: "etcd Dashboard",
|
||||
etcdAccessControl: "etcd Access Control",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
gridfs: "GridFS",
|
||||
vector: "Vector",
|
||||
@@ -3739,6 +3844,7 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Path prefix, e.g. /app/",
|
||||
newKey: "New Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/es";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "Clave (Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "Renombrar",
|
||||
clone: "Clonar",
|
||||
copyKey: "Copiar clave",
|
||||
export: "Exportar",
|
||||
value: "Value",
|
||||
valueContent: "Valor",
|
||||
format: "Formato",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "Vigilar",
|
||||
stopWatching: "Detener",
|
||||
workspace: {
|
||||
overview: "Resumen",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "Actualizar capacidades",
|
||||
loading: "Cargando capacidades de Consul...",
|
||||
agentIdentity: "Identidad del agente",
|
||||
capabilities: "Capacidades",
|
||||
version: "Versión",
|
||||
edition: "Edición",
|
||||
datacenter: "Centro de datos",
|
||||
node: "Nodo del agente",
|
||||
mode: "Modo del agente",
|
||||
agent: "API del agente",
|
||||
acl: "ACL",
|
||||
namespaces: "Espacios de nombres",
|
||||
partitions: "Particiones administrativas",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Servidor",
|
||||
clientMode: "Cliente",
|
||||
capability: {
|
||||
supported: "Compatible",
|
||||
unsupported: "No compatible",
|
||||
forbidden: "Sin permiso",
|
||||
unknown: "Desconocido",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -438,6 +514,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "Elegir certificado de cliente",
|
||||
etcdClientKeyBrowse: "Elegir clave privada de cliente",
|
||||
etcdClientCertPairRequired: "El certificado y la clave privada deben proporcionarse juntos.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "Cadena de conexión",
|
||||
zookeeperConnectStringHint: "Entradas host:puerto separadas por coma o por línea. Déjalo en blanco para usar el host y puerto de arriba.",
|
||||
zookeeperCreateModePersistent: "Persistente",
|
||||
@@ -1119,6 +1223,7 @@ export default withEnglishFallback({
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
vector: "Vector",
|
||||
users: "Usuarios y Privilegios",
|
||||
executionSummary: "Resumen",
|
||||
@@ -3586,6 +3691,7 @@ export default withEnglishFallback({
|
||||
leaseCreated: "Lease {id} creado",
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefijo de ruta, ej. /app/",
|
||||
newKey: "Nuevo Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/it";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "Chiave (Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "Rinomina",
|
||||
clone: "Clona",
|
||||
copyKey: "Copia chiave",
|
||||
export: "Esporta",
|
||||
value: "Value",
|
||||
valueContent: "Valore",
|
||||
format: "Formato",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "Monitora",
|
||||
stopWatching: "Interrompi",
|
||||
workspace: {
|
||||
overview: "Panoramica",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "Aggiorna capacità",
|
||||
loading: "Caricamento delle capacità Consul...",
|
||||
agentIdentity: "Identità agent",
|
||||
capabilities: "Capacità",
|
||||
version: "Versione",
|
||||
edition: "Edizione",
|
||||
datacenter: "Datacenter",
|
||||
node: "Nodo agent",
|
||||
mode: "Modalità agent",
|
||||
agent: "API agent",
|
||||
acl: "ACL",
|
||||
namespaces: "Namespace",
|
||||
partitions: "Partizioni amministrative",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "Supportato",
|
||||
unsupported: "Non supportato",
|
||||
forbidden: "Nessun permesso",
|
||||
unknown: "Sconosciuto",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
name: "DBX",
|
||||
@@ -437,6 +513,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "Scegli certificato client",
|
||||
etcdClientKeyBrowse: "Scegli chiave privata client",
|
||||
etcdClientCertPairRequired: "Il certificato client e la chiave privata devono essere forniti insieme.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "Stringa di connessione",
|
||||
zookeeperConnectStringHint: "Voci host:porta separate da virgola o da riga. Lascia vuoto per usare l'host e la porta sopra.",
|
||||
zookeeperCreateModePersistent: "Persistente",
|
||||
@@ -1117,6 +1221,7 @@ export default withEnglishFallback({
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
vector: "Vector",
|
||||
objects: "Oggetti",
|
||||
@@ -3584,6 +3689,7 @@ export default withEnglishFallback({
|
||||
leaseCreated: "Lease {id} creato",
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefisso percorso, es. /app/",
|
||||
newKey: "Nuovo Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/ja";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "キー(Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "名前を変更",
|
||||
clone: "複製",
|
||||
copyKey: "Keyをコピー",
|
||||
export: "エクスポート",
|
||||
value: "Value",
|
||||
valueContent: "値",
|
||||
format: "形式",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "監視",
|
||||
stopWatching: "停止",
|
||||
workspace: {
|
||||
overview: "概要",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "機能情報を更新",
|
||||
loading: "Consul機能を読み込み中...",
|
||||
agentIdentity: "Agent情報",
|
||||
capabilities: "機能",
|
||||
version: "バージョン",
|
||||
edition: "エディション",
|
||||
datacenter: "データセンター",
|
||||
node: "Agentノード",
|
||||
mode: "Agentモード",
|
||||
agent: "Agent API",
|
||||
acl: "ACL",
|
||||
namespaces: "名前空間",
|
||||
partitions: "管理パーティション",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "対応",
|
||||
unsupported: "非対応",
|
||||
forbidden: "権限なし",
|
||||
unknown: "不明",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -705,6 +781,34 @@ export default withEnglishFallback({
|
||||
colorBlue: "青",
|
||||
colorPurple: "紫",
|
||||
colorCustom: "カスタム色",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "接続文字列",
|
||||
zookeeperConnectStringHint: "カンマまたは改行区切りの host:port エントリ。空欄の場合は上のホストとポートを使用します。",
|
||||
zookeeperCreateModePersistent: "永続",
|
||||
@@ -1181,6 +1285,7 @@ export default withEnglishFallback({
|
||||
fixedTabs: "固定タブ",
|
||||
openDataTabs: "開いているテーブル",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
vector: "ベクター",
|
||||
gridfs: "GridFS",
|
||||
etcdDashboard: "etcd ダッシュボード",
|
||||
@@ -2670,6 +2775,7 @@ export default withEnglishFallback({
|
||||
gridfs: "GridFS",
|
||||
buckets: "バケット",
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "パスプレフィックス(例: /app/)",
|
||||
newKey: "新しいZnode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/ko";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "키(Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "이름 변경",
|
||||
clone: "복제",
|
||||
copyKey: "Key 복사",
|
||||
export: "내보내기",
|
||||
value: "Value",
|
||||
valueContent: "값",
|
||||
format: "형식",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "감시",
|
||||
stopWatching: "중지",
|
||||
workspace: {
|
||||
overview: "개요",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "기능 정보 새로 고침",
|
||||
loading: "Consul 기능을 불러오는 중...",
|
||||
agentIdentity: "Agent 정보",
|
||||
capabilities: "기능",
|
||||
version: "버전",
|
||||
edition: "에디션",
|
||||
datacenter: "데이터센터",
|
||||
node: "Agent 노드",
|
||||
mode: "Agent 모드",
|
||||
agent: "Agent API",
|
||||
acl: "ACL",
|
||||
namespaces: "네임스페이스",
|
||||
partitions: "관리 파티션",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "지원",
|
||||
unsupported: "지원 안 함",
|
||||
forbidden: "권한 없음",
|
||||
unknown: "알 수 없음",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -452,6 +528,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "클라이언트 인증서 선택",
|
||||
etcdClientKeyBrowse: "클라이언트 개인 키 선택",
|
||||
etcdClientCertPairRequired: "클라이언트 인증서와 개인 키를 함께 제공해야 합니다.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "연결 문자열",
|
||||
zookeeperConnectStringHint: "쉼표 또는 줄바꿈으로 구분된 host:port 항목. 비워두면 위의 호스트와 포트를 사용합니다.",
|
||||
zookeeperCreateModePersistent: "영구",
|
||||
@@ -1029,6 +1133,7 @@ export default withEnglishFallback({
|
||||
etcd: "etcd",
|
||||
etcdDashboard: "etcd 대시보드",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
gridfs: "GridFS",
|
||||
vector: "Vector",
|
||||
@@ -3262,6 +3367,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "경로 접두사, 예: /app/",
|
||||
newKey: "새 Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/pt-BR";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "Chave (Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "Renomear",
|
||||
clone: "Clonar",
|
||||
copyKey: "Copiar chave",
|
||||
export: "Exportar",
|
||||
value: "Value",
|
||||
valueContent: "Valor",
|
||||
format: "Formato",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "Monitorar",
|
||||
stopWatching: "Parar",
|
||||
workspace: {
|
||||
overview: "Visão geral",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "Atualizar recursos",
|
||||
loading: "Carregando recursos do Consul...",
|
||||
agentIdentity: "Identidade do agente",
|
||||
capabilities: "Recursos",
|
||||
version: "Versão",
|
||||
edition: "Edição",
|
||||
datacenter: "Datacenter",
|
||||
node: "Nó do agente",
|
||||
mode: "Modo do agente",
|
||||
agent: "API do agente",
|
||||
acl: "ACL",
|
||||
namespaces: "Namespaces",
|
||||
partitions: "Partições administrativas",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Servidor",
|
||||
clientMode: "Cliente",
|
||||
capability: {
|
||||
supported: "Compatível",
|
||||
unsupported: "Não compatível",
|
||||
forbidden: "Sem permissão",
|
||||
unknown: "Desconhecido",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -438,6 +514,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "Escolher certificado do cliente",
|
||||
etcdClientKeyBrowse: "Escolher chave privada do cliente",
|
||||
etcdClientCertPairRequired: "O certificado e a chave privada do cliente devem ser fornecidos juntos.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "String de Conexão",
|
||||
zookeeperConnectStringHint: "Entradas host:porta separadas por vírgula ou linha. Deixe em branco para usar o host e porta acima.",
|
||||
zookeeperCreateModePersistent: "Persistente",
|
||||
@@ -1118,6 +1222,7 @@ export default withEnglishFallback({
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
vector: "Vector",
|
||||
objects: "Objetos",
|
||||
@@ -3586,6 +3691,7 @@ export default withEnglishFallback({
|
||||
leaseCreated: "Lease {id} criado",
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefixo de caminho, ex.: /app/",
|
||||
newKey: "Novo Znode",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/zh-CN";
|
||||
import { consul } from "./consulZhCN";
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -459,6 +460,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "选择客户端证书",
|
||||
etcdClientKeyBrowse: "选择客户端私钥",
|
||||
etcdClientCertPairRequired: "客户端证书和私钥必须一起填写。",
|
||||
consulAddress: "Consul API 地址",
|
||||
consulAddressInvalid: "请输入不包含凭据、查询参数或片段的 HTTP/HTTPS Consul API 绝对地址。",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "可选的 Consul ACL Token",
|
||||
consulDatacenter: "数据中心",
|
||||
consulNamespace: "命名空间",
|
||||
consulPartition: "管理分区",
|
||||
consulAgentTargetNode: "目标 Agent 节点",
|
||||
consulAgentTargetAddress: "Agent 直连地址",
|
||||
consulAgentTargetHint: "Agent 写操作必填。DNS/负载均衡和 Transport Layer 连接保持只读。",
|
||||
consulMeshFeatures: "服务网格",
|
||||
consulMeshVisible: "显示服务网格高级功能",
|
||||
consulMeshVisibleHint: "仅在需要管理 Connect、服务意图或对等连接时开启;关闭后主导航不会显示服务网格。",
|
||||
consulOperatorWrites: "Operator 写入",
|
||||
consulOperatorVisible: "显示实验性 Operator 工作台",
|
||||
consulOperatorSnapshotRestore: "启用 Snapshot 恢复",
|
||||
consulOperatorAutopilot: "启用 Autopilot 修改",
|
||||
consulOperatorRaft: "启用 Raft 修改",
|
||||
consulOperatorKeyring: "启用 Keyring 修改",
|
||||
consulOperatorLicense: "启用 License 修改",
|
||||
consulAgentTargetIncomplete: "目标 Agent 节点和直连地址必须同时配置。",
|
||||
consulAgentTargetAddressMismatch: "Agent 直连地址必须与 Consul 地址的主机一致。",
|
||||
consulConsistency: "读取一致性",
|
||||
consulConsistencyDefault: "默认",
|
||||
consulConsistencyStale: "允许陈旧读取",
|
||||
consulConsistencyConsistent: "强一致读取",
|
||||
consulTlsSkipVerify: "跳过证书校验",
|
||||
consulTlsSkipVerifyHint: "仅用于可信的自签名 Consul 服务端。",
|
||||
zookeeperConnectString: "Connect String",
|
||||
zookeeperConnectStringHint: "可用逗号或换行分隔 host:port,并可在末尾填写整个集群共用的 /chroot;留空时使用上面的 host 和端口。",
|
||||
zookeeperClusterInputHint: "ZooKeeper 集群可在上方 URL 中填写全部节点(例如 zookeeper://zk-1:2181,zk-2:2181/app),也可在下方 Connect String 中填写。",
|
||||
@@ -1140,6 +1169,7 @@ export default withEnglishFallback({
|
||||
etcdDashboard: "etcd 大盘",
|
||||
etcdAccessControl: "etcd 访问控制",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
gridfs: "GridFS",
|
||||
vector: "向量",
|
||||
@@ -3357,7 +3387,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
etcd: {
|
||||
prefixPlaceholder: "搜索 Key 路径,例如 /app/ 或 service",
|
||||
newKey: "新建 Key",
|
||||
newKey: "新建键(Key)",
|
||||
loadingKeys: "正在加载 Key...",
|
||||
empty: "未找到 Key",
|
||||
loadMore: "加载更多",
|
||||
@@ -3737,6 +3767,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "路径前缀,例如 /app/",
|
||||
newKey: "新建 Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/zh-TW";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key 前綴,例如 app/ 或 services/",
|
||||
newKey: "新增 Key",
|
||||
loadingKeys: "正在載入 Key...",
|
||||
empty: "找不到 Key",
|
||||
loadMore: "載入更多",
|
||||
selectKey: "選擇一個 Key 以檢視其值",
|
||||
loadingValue: "正在載入值...",
|
||||
notFound: "找不到 Key",
|
||||
edit: "編輯",
|
||||
editKey: "編輯 Key",
|
||||
delete: "刪除",
|
||||
deleteTitle: "刪除 Consul KV Key",
|
||||
keyLabel: "鍵(Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key 不能為空",
|
||||
rootReadonly: "根前綴無法修改,請輸入具體 Key 路徑。",
|
||||
saved: "Key 已儲存",
|
||||
deleted: "Key 已刪除",
|
||||
base64Readonly: "請在 Base64 模式下編輯二進位值。",
|
||||
rename: "重新命名",
|
||||
clone: "複製",
|
||||
copyKey: "複製 Key",
|
||||
export: "匯出",
|
||||
value: "值",
|
||||
valueContent: "值",
|
||||
format: "格式",
|
||||
metadata: "KV 中繼資料",
|
||||
prettyJson: "格式化",
|
||||
invalidJson: "JSON 格式無效",
|
||||
summarySize: "大小",
|
||||
conflict: "此 Key 自載入後已變更,請重新整理後再試。",
|
||||
keyAlreadyExists: "同名 Key 已存在,請使用其他名稱或編輯現有 Key。",
|
||||
aclFiltered: "部分 Consul KV Key 已被 ACL 原則過濾。",
|
||||
locked: "此 Key 正由 Consul Session 持有,無法編輯或刪除。",
|
||||
valueTooLarge: "Consul KV 值不能超過 512 KiB。",
|
||||
watchKey: "監視",
|
||||
stopWatching: "停止",
|
||||
workspace: {
|
||||
overview: "概覽",
|
||||
kv: "KV",
|
||||
services: "服務",
|
||||
health: "健康檢查",
|
||||
sessions: "Session",
|
||||
scope: "作用域",
|
||||
mesh: "Service Mesh",
|
||||
tools: "工具",
|
||||
operator: "Operator",
|
||||
refresh: "重新整理能力資訊",
|
||||
loading: "正在載入 Consul 能力...",
|
||||
agentIdentity: "Agent 身分",
|
||||
capabilities: "能力狀態",
|
||||
version: "版本",
|
||||
edition: "版本類型",
|
||||
datacenter: "資料中心",
|
||||
node: "Agent 節點",
|
||||
mode: "Agent 模式",
|
||||
agent: "Agent API",
|
||||
acl: "ACL",
|
||||
namespaces: "命名空間",
|
||||
partitions: "管理分區",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "支援",
|
||||
unsupported: "不支援",
|
||||
forbidden: "無權限",
|
||||
unknown: "未知",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -438,6 +514,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "選擇用戶端憑證",
|
||||
etcdClientKeyBrowse: "選擇用戶端私鑰",
|
||||
etcdClientCertPairRequired: "用戶端憑證和私鑰必須一起填寫。",
|
||||
consulAddress: "Consul API 位址",
|
||||
consulAddressInvalid: "請輸入不包含憑證、查詢參數或片段的 HTTP/HTTPS Consul API 絕對位址。",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "選填的 Consul ACL Token",
|
||||
consulDatacenter: "資料中心",
|
||||
consulNamespace: "命名空間",
|
||||
consulPartition: "管理分區",
|
||||
consulAgentTargetNode: "目標 Agent 節點",
|
||||
consulAgentTargetAddress: "Agent 直連位址",
|
||||
consulAgentTargetHint: "Agent 寫入操作必填。DNS/負載均衡和 Transport Layer 連線維持唯讀。",
|
||||
consulMeshFeatures: "服務網格",
|
||||
consulMeshVisible: "顯示服務網格進階功能",
|
||||
consulMeshVisibleHint: "僅在需要管理 Connect、服務意圖或對等連線時開啟;關閉後主導覽不會顯示服務網格。",
|
||||
consulOperatorWrites: "Operator 寫入",
|
||||
consulOperatorVisible: "顯示實驗性 Operator 工作台",
|
||||
consulOperatorSnapshotRestore: "啟用 Snapshot 還原",
|
||||
consulOperatorAutopilot: "啟用 Autopilot 修改",
|
||||
consulOperatorRaft: "啟用 Raft 修改",
|
||||
consulOperatorKeyring: "啟用 Keyring 修改",
|
||||
consulOperatorLicense: "啟用 License 修改",
|
||||
consulAgentTargetIncomplete: "目標 Agent 節點和直連位址必須同時設定。",
|
||||
consulAgentTargetAddressMismatch: "Agent 直連位址必須與 Consul 位址的主機一致。",
|
||||
consulConsistency: "讀取一致性",
|
||||
consulConsistencyDefault: "預設",
|
||||
consulConsistencyStale: "允許陳舊讀取",
|
||||
consulConsistencyConsistent: "強一致讀取",
|
||||
consulTlsSkipVerify: "略過憑證驗證",
|
||||
consulTlsSkipVerifyHint: "僅用於可信任的自簽章 Consul 端點。",
|
||||
zookeeperConnectString: "連線字串",
|
||||
zookeeperConnectStringHint: "以逗號或換行分隔的 host:port 項目。留空則使用上方的主機和連接埠。",
|
||||
zookeeperCreateModePersistent: "持久",
|
||||
@@ -1117,6 +1221,7 @@ export default withEnglishFallback({
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
vector: "向量",
|
||||
objects: "物件",
|
||||
@@ -6036,6 +6141,7 @@ export default withEnglishFallback({
|
||||
leaseCreated: "已建立 Lease {id}",
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "路徑前綴,例如 /app/",
|
||||
newKey: "新增 Znode",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
function section(startMarker: string, endMarker: string): string {
|
||||
const start = dialogSource.indexOf(startMarker);
|
||||
const end = dialogSource.indexOf(endMarker, start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return dialogSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe("Consul connection dialog", () => {
|
||||
it("keeps Operator feature switches in the Consul form instead of the JDBC form", () => {
|
||||
const jdbcForm = section("<!-- JDBC: optional external plugin -->", "<!-- Local database files: file path only -->");
|
||||
const consulForm = section("<!-- Consul KV: HTTP endpoint, ACL token and scope -->", "<!-- etcd: endpoints, user, password, TLS -->");
|
||||
const operatorModels = ["consulOperatorVisible", "consulOperatorSnapshotRestoreEnabled", "consulOperatorAutopilotWriteEnabled", "consulOperatorRaftWriteEnabled", "consulOperatorKeyringWriteEnabled", "consulOperatorLicenseWriteEnabled"];
|
||||
|
||||
for (const model of operatorModels) {
|
||||
expect(jdbcForm).not.toContain(`v-model="${model}"`);
|
||||
expect(consulForm).toContain(`v-model="${model}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLazyKvKeyTreeState, replaceLazyKvChildren } from "@/lib/kv/slashPrefixLazyKeyTree";
|
||||
|
||||
describe("slashPrefixLazyKeyTree", () => {
|
||||
it("treats a trailing-slash Consul Key with no children as a leaf after expansion", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
replaceLazyKvChildren(state, null, [{ key: "feature/", hasValue: true }], null);
|
||||
|
||||
const node = state.nodeByKey.get("feature/");
|
||||
expect(node?.hasChildren).toBe(true);
|
||||
|
||||
replaceLazyKvChildren(state, "feature/", [], null, { filteredByAcls: false });
|
||||
expect(state.nodeByKey.get("feature/")?.hasChildren).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the directory affordance when ACL filtering makes an empty listing inconclusive", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
replaceLazyKvChildren(state, null, [{ key: "feature/", hasValue: true }], null);
|
||||
|
||||
replaceLazyKvChildren(state, "feature/", [], null, { filteredByAcls: true });
|
||||
expect(state.nodeByKey.get("feature/")?.hasChildren).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ describe("sidebar tree item layout", () => {
|
||||
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);
|
||||
expect(canTreeNodeShowExpander({ type: "consul-overview", childCount: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("aligns comments to the longest sibling name without crossing parent groups", () => {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { objectSourceKindForTreeNode, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, shouldActivateTreeNodeOnSingleClick, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick";
|
||||
|
||||
describe("treeNodeClick", () => {
|
||||
it("opens synonym nodes as synonym source", () => {
|
||||
expect(objectSourceKindForTreeNode("synonym")).toBe("SYNONYM");
|
||||
expect(treeNodeRowAction("synonym", false)).toBe("open-source");
|
||||
});
|
||||
|
||||
it("opens Consul navigation entries on one click regardless of object activation preference", () => {
|
||||
expect(isDirectNavigationTreeNode("consul-root")).toBe(true);
|
||||
expect(isDirectNavigationTreeNode("consul-overview")).toBe(true);
|
||||
expect(isDirectNavigationTreeNode("table")).toBe(false);
|
||||
expect(shouldActivateTreeNodeOnSingleClick("consul-root", "double")).toBe(true);
|
||||
expect(shouldActivateTreeNodeOnSingleClick("consul-overview", "double")).toBe(true);
|
||||
expect(shouldActivateTreeNodeOnSingleClick("table", "double")).toBe(false);
|
||||
expect(treeNodeRowAction("consul-root", false, "double")).toBe("toggle");
|
||||
expect(treeNodeRowAction("consul-overview", false, "double")).toBe("toggle");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLazyKvKeyTreeState, createZooKeeperChildPathDraft, flattenLazyKvKeyTree, replaceLazyKvChildren, resetLazyKvKeyTree } from "@/lib/zookeeper/zookeeperLazyKeyTree";
|
||||
import { createLazyKvKeyTreeState, createZooKeeperChildPathDraft, flattenLazyKvKeyTree, replaceLazyKvChildren, replaceLazyKvFocusedRoot, resetLazyKvKeyTree } from "@/lib/zookeeper/zookeeperLazyKeyTree";
|
||||
|
||||
describe("zookeeper lazy key tree", () => {
|
||||
it("stores only the current path direct children on reset", () => {
|
||||
@@ -49,4 +49,41 @@ describe("zookeeper lazy key tree", () => {
|
||||
expect(createZooKeeperChildPathDraft("/app")).toBe("/app/");
|
||||
expect(createZooKeeperChildPathDraft("app/")).toBe("/app/");
|
||||
});
|
||||
|
||||
it("keeps an exact focused Key without children as a leaf", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
|
||||
replaceLazyKvFocusedRoot(state, { key: "dbx-demo/locks/persistent", numChildren: 0, valueSize: 12, hasValue: true }, [], null);
|
||||
|
||||
const leaf = state.nodeByKey.get("dbx-demo/locks/persistent");
|
||||
expect(leaf?.hasChildren).toBe(false);
|
||||
expect(leaf?.hasValue).toBe(true);
|
||||
expect(leaf?.label).toBe("persistent");
|
||||
});
|
||||
|
||||
it("keeps a real Consul Key and its child prefix as one dual-purpose node", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
|
||||
replaceLazyKvFocusedRoot(state, { key: "applications", numChildren: 1, valueSize: 7, hasValue: true }, [{ key: "applications/api", numChildren: 0, valueSize: 3 }], null);
|
||||
|
||||
const root = state.nodeByKey.get("applications");
|
||||
expect(root?.hasValue).toBe(true);
|
||||
expect(root?.hasChildren).toBe(true);
|
||||
expect(root?.children.map((child) => child.key)).toEqual(["applications/api"]);
|
||||
});
|
||||
|
||||
it("requires an exact GET before treating a trailing-slash Consul path as a Key", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
|
||||
replaceLazyKvChildren(state, null, [{ key: "applications/", numChildren: 2 }], null);
|
||||
|
||||
const prefix = state.nodeByKey.get("applications/");
|
||||
expect(prefix?.hasChildren).toBe(true);
|
||||
expect(prefix?.hasValue).toBeNull();
|
||||
|
||||
replaceLazyKvFocusedRoot(state, { key: "applications/", numChildren: 0, hasValue: true }, [], null);
|
||||
const exactTrailingSlashKey = state.nodeByKey.get("applications/");
|
||||
expect(exactTrailingSlashKey?.hasChildren).toBe(false);
|
||||
expect(exactTrailingSlashKey?.hasValue).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Scope = "global" | "read" | "write";
|
||||
type Method = "GET" | "PUT" | "POST" | "DELETE";
|
||||
type Contract = { domain: string; file: string; method: Method; path: string; needle?: string; methodNeedle?: string; scopeNeedle?: string; scope: Scope };
|
||||
|
||||
const contracts: Contract[] = [
|
||||
{ domain: "capabilities", file: "capabilities.rs", method: "GET", path: "/v1/agent/self", scope: "global" },
|
||||
{ domain: "capabilities", file: "capabilities.rs", method: "GET", path: "/v1/acl/token/self", scope: "read" },
|
||||
{ domain: "kv", file: "kv.rs", method: "GET", path: "/v1/kv/{key-or-prefix}", needle: "/v1/kv/{encoded}", scope: "read" },
|
||||
{ domain: "kv", file: "kv.rs", method: "PUT", path: "/v1/kv/{key}", needle: "/v1/kv/{encoded}", scope: "write" },
|
||||
{ domain: "kv", file: "kv.rs", method: "DELETE", path: "/v1/kv/{key}", needle: "/v1/kv/{encoded}", scope: "write" },
|
||||
{ domain: "txn", file: "txn.rs", method: "PUT", path: "/v1/txn", scope: "write" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/kv/{key-or-prefix}", needle: "/v1/kv/{encoded}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/catalog/nodes", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/catalog/services", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/catalog/service/{service}", needle: "/v1/catalog/service/{}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/catalog/node/{node}", needle: "/v1/catalog/node/{}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/health/node/{node}", needle: "/v1/health/node/{}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/health/checks/{service}", needle: "/v1/health/checks/{}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/health/service/{service}", needle: "/v1/health/service/{}{suffix}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/health/state/{state}", needle: "/v1/health/state/{}", scope: "read" },
|
||||
{ domain: "status", file: "status.rs", method: "GET", path: "/v1/status/leader", scope: "global" },
|
||||
{ domain: "status", file: "status.rs", method: "GET", path: "/v1/status/peers", scope: "global" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/datacenters", scope: "global" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/nodes", scope: "read" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/services", scope: "read" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/service/{service}", needle: "/v1/catalog/service/{}", scope: "read" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/node/{node}", needle: "/v1/catalog/node/{}", scope: "read" },
|
||||
{ domain: "health", file: "health.rs", method: "GET", path: "/v1/health/node/{node}", needle: "/v1/health/node/{}", methodNeedle: "read_list(", scope: "read" },
|
||||
{ domain: "health", file: "health.rs", method: "GET", path: "/v1/health/checks/{service}", needle: "/v1/health/checks/{}", methodNeedle: "read_list(", scope: "read" },
|
||||
{ domain: "health", file: "health.rs", method: "GET", path: "/v1/health/service/{service}", needle: "/v1/health/service/{}", methodNeedle: "read_list(", scope: "read" },
|
||||
{ domain: "health", file: "health.rs", method: "GET", path: "/v1/health/state/{state}", needle: "/v1/health/state/{state}", methodNeedle: "read_list(", scope: "read" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/self", scope: "global" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/members", scope: "global" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/metrics", scope: "global" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/services", scope: "read" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/service/{id}", needle: "/v1/agent/service/{}", scope: "read" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/checks", scope: "read" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/service/register", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/service/deregister/{id}", needle: "/v1/agent/service/deregister/{}", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/service/maintenance/{id}", needle: "/v1/agent/service/maintenance/{}", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/check/register", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/check/deregister/{id}", needle: "/v1/agent/check/deregister/{}", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/check/update/{id}", needle: "/v1/agent/check/update/{}", scope: "write" },
|
||||
{ domain: "session", file: "session.rs", method: "GET", path: "/v1/session/list", scope: "read" },
|
||||
{ domain: "session", file: "session.rs", method: "GET", path: "/v1/session/node/{node}", needle: "/v1/session/node/{}", scope: "read" },
|
||||
{ domain: "session", file: "session.rs", method: "GET", path: "/v1/session/info/{id}", needle: "/v1/session/info/{}", scope: "read" },
|
||||
{ domain: "session", file: "session.rs", method: "PUT", path: "/v1/session/create", scope: "write" },
|
||||
{ domain: "session", file: "session.rs", method: "PUT", path: "/v1/session/renew/{id}", needle: "/v1/session/renew/{}", scope: "write" },
|
||||
{ domain: "session", file: "session.rs", method: "PUT", path: "/v1/session/destroy/{id}", needle: "/v1/session/destroy/{}", scope: "write" },
|
||||
{ domain: "acl", file: "acl.rs", method: "GET", path: "/v1/acl/token/self", scope: "read" },
|
||||
{ domain: "acl", file: "acl.rs", method: "PUT", path: "/v1/acl/token/{id}/clone", needle: "/v1/acl/token/{accessor_id}/clone", scope: "write" },
|
||||
{ domain: "acl", file: "acl.rs", method: "GET", path: "/v1/acl/{tokens|policies|roles|auth-methods|binding-rules|templated-policies}", needle: "/v1/acl/tokens", scope: "read" },
|
||||
{ domain: "acl", file: "acl.rs", method: "PUT", path: "/v1/acl/{token|policy|role|auth-method|binding-rule}", needle: "/v1/acl/token", scope: "write" },
|
||||
{ domain: "acl", file: "acl.rs", method: "PUT", path: "/v1/acl/{kind}/{id}", needle: "(Method::PUT, item_path", scope: "write" },
|
||||
{ domain: "acl", file: "acl.rs", method: "DELETE", path: "/v1/acl/{kind}/{id}", needle: "item_path(kind, id)", scope: "write" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "GET", path: "/v1/namespaces", scope: "read" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "GET", path: "/v1/partitions", scope: "read" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "GET", path: "/v1/{namespace|partition}/{name}", needle: "/v1/namespace/{name}", scope: "read" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "PUT", path: "/v1/{namespace|partition}", needle: "/v1/namespace", scope: "write" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "PUT", path: "/v1/{namespace|partition}/{name}", needle: "Some(name) => (Method::PUT", scope: "write" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "DELETE", path: "/v1/{namespace|partition}/{name}", needle: "client.send(Method::DELETE", scopeNeedle: "append_enterprise_target", scope: "write" },
|
||||
{ domain: "mesh-config", file: "mesh/config_entries.rs", method: "GET", path: "/v1/config/{kind}", needle: "/v1/config/{}", scope: "read" },
|
||||
{ domain: "mesh-config", file: "mesh/config_entries.rs", method: "GET", path: "/v1/config/{kind}/{name}", needle: "/v1/config/{}/{}", scope: "read" },
|
||||
{ domain: "mesh-config", file: "mesh/config_entries.rs", method: "PUT", path: "/v1/config", scope: "write" },
|
||||
{ domain: "mesh-config", file: "mesh/config_entries.rs", method: "DELETE", path: "/v1/config/{kind}/{name}", needle: "config_path(kind, name)", scope: "write" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions", scope: "read" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions/{id}", needle: "/v1/connect/intentions/{}", scope: "read" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions/exact", scope: "read" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "PUT", path: "/v1/connect/intentions/exact", scope: "write" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "DELETE", path: "/v1/connect/intentions/{id|exact}", needle: "send_json(Method::DELETE", scope: "write" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions/match", scope: "read" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions/check", scope: "read" },
|
||||
{ domain: "discovery", file: "mesh/discovery.rs", method: "GET", path: "/v1/discovery-chain/{service}", needle: "/v1/discovery-chain/{service}", scope: "read" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "GET", path: "/v1/peerings", scope: "read" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "GET", path: "/v1/peering/{name}", needle: "/v1/peering/{}", scope: "read" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "POST", path: "/v1/peering/token", scope: "write" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "POST", path: "/v1/peering/establish", scope: "write" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "DELETE", path: "/v1/peering/{name}", needle: "send_json(Method::DELETE", scope: "write" },
|
||||
{ domain: "exported-services", file: "mesh/exported_services.rs", method: "GET", path: "/v1/exported-services", scope: "read" },
|
||||
{ domain: "exported-services", file: "mesh/exported_services.rs", method: "PUT", path: "/v1/config", needle: "consul_mesh_config_apply_core", methodNeedle: "consul_mesh_config_apply_core", scopeNeedle: "consul_mesh_config_apply_core", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/query", scope: "read" },
|
||||
{ domain: "tools", file: "tools.rs", method: "POST", path: "/v1/query", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "PUT", path: "/v1/query/{id}", needle: "/v1/query/{}", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "DELETE", path: "/v1/query/{id}", needle: "send_json(Method::DELETE", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/query/{id}/execute", needle: "/v1/query/{}/execute", scope: "read" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/query/{id}/explain", needle: "/v1/query/{}/explain", scope: "read" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/event/list", scope: "read" },
|
||||
{ domain: "tools", file: "tools.rs", method: "PUT", path: "/v1/event/fire/{name}", needle: "/v1/event/fire/{}", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/coordinate/nodes", scope: "read" },
|
||||
{ domain: "operator", file: "operator.rs", method: "GET", path: "/v1/operator/{autopilot|raft|usage|license}", needle: "/v1/operator/autopilot/configuration", scope: "read" },
|
||||
{ domain: "operator", file: "operator.rs", method: "POST", path: "/v1/operator/audit-hash", scope: "read" },
|
||||
{ domain: "operator", file: "operator.rs", method: "GET", path: "/v1/snapshot", scope: "read" },
|
||||
{ domain: "operator", file: "operator.rs", method: "PUT", path: "/v1/snapshot", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "PUT", path: "/v1/operator/autopilot/configuration", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "POST", path: "/v1/operator/raft/transfer-leader", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "DELETE", path: "/v1/operator/raft/peer", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "POST", path: "/v1/operator/keyring", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "PUT", path: "/v1/operator/keyring", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "DELETE", path: "/v1/operator/keyring", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "PUT", path: "/v1/operator/license", scope: "write" },
|
||||
];
|
||||
|
||||
const sourceCache = new Map<string, string>();
|
||||
function source(file: string): string {
|
||||
if (!sourceCache.has(file)) sourceCache.set(file, readFileSync(join(process.cwd(), "crates/dbx-core/src/consul", file), "utf8"));
|
||||
return sourceCache.get(file)!;
|
||||
}
|
||||
|
||||
const methodScopeSnapshot = {
|
||||
"kv.rs": [4, 3, 0, 1, 4, 4],
|
||||
"txn.rs": [0, 1, 0, 0, 0, 1],
|
||||
"blocking.rs": [2, 0, 0, 0, 2, 0],
|
||||
"status.rs": [2, 0, 0, 0, 0, 0],
|
||||
"catalog.rs": [2, 0, 0, 0, 5, 0],
|
||||
"health.rs": [0, 0, 0, 0, 4, 0],
|
||||
"agent.rs": [6, 2, 0, 0, 3, 2],
|
||||
"session.rs": [1, 3, 0, 0, 3, 3],
|
||||
"acl.rs": [9, 3, 0, 1, 9, 3],
|
||||
"enterprise.rs": [5, 2, 0, 1, 3, 2],
|
||||
"mesh/config_entries.rs": [2, 1, 0, 1, 2, 2],
|
||||
"mesh/intentions.rs": [6, 1, 0, 2, 6, 3],
|
||||
"mesh/discovery.rs": [1, 0, 0, 0, 1, 0],
|
||||
"mesh/peering.rs": [2, 0, 2, 1, 2, 3],
|
||||
"mesh/exported_services.rs": [1, 0, 0, 0, 1, 0],
|
||||
"tools.rs": [7, 2, 1, 1, 5, 3],
|
||||
"operator.rs": [2, 4, 3, 2, 3, 6],
|
||||
"capabilities.rs": [3, 0, 1, 0, 3, 0],
|
||||
} satisfies Record<string, [number, number, number, number, number, number]>;
|
||||
|
||||
describe("Consul Core endpoint contract", () => {
|
||||
it("enumerates every supported domain without duplicate rows", () => {
|
||||
expect(new Set(contracts.map(({ domain }) => domain))).toEqual(new Set(["capabilities", "kv", "txn", "blocking", "status", "catalog", "health", "agent", "session", "acl", "enterprise", "mesh-config", "intentions", "discovery", "peering", "exported-services", "tools", "operator"]));
|
||||
const identities = contracts.map(({ domain, method, path, scope }) => `${domain}:${method}:${path}:${scope}`);
|
||||
expect(new Set(identities).size).toBe(identities.length);
|
||||
});
|
||||
|
||||
it.each(contracts)("$domain $method $path uses $scope scope", ({ file, method, path, needle, methodNeedle, scopeNeedle, scope }) => {
|
||||
const code = source(file);
|
||||
const evidence = needle ?? path;
|
||||
expect(code, `${file} path evidence for ${path}`).toContain(evidence);
|
||||
expect(code, `${file} method evidence for ${method} ${path}`).toContain(methodNeedle ?? `Method::${method}`);
|
||||
if (scopeNeedle) expect(code, `${file} scope delegation for ${path}`).toContain(scopeNeedle);
|
||||
else if (scope === "read") expect(code).toMatch(/append_scope\(&mut url, true\)|request_json\([\s\S]*?true|send_json\([\s\S]*?true|read_list\(/);
|
||||
else if (scope === "write") expect(code).toMatch(/append_scope\(&mut url, false\)|request_json\([\s\S]*?false|send_json\([\s\S]*?false|ensure_writable/);
|
||||
});
|
||||
|
||||
it("centralizes scoped requests and ACL token headers", () => {
|
||||
const client = source("client.rs");
|
||||
expect(client).toContain("self.append_scope(&mut url, read);");
|
||||
expect(client).toContain('request.header("X-Consul-Token", &self.config.token)');
|
||||
expect(client.match(/request\.header\("X-Consul-Token"/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps gossip keys in the request body and maps each operation to its official method", () => {
|
||||
const operator = source("operator.rs");
|
||||
expect(operator).toContain("ConsulKeyringOperation::Install => Method::POST");
|
||||
expect(operator).toContain("ConsulKeyringOperation::Use => Method::PUT");
|
||||
expect(operator).toContain("ConsulKeyringOperation::Remove => Method::DELETE");
|
||||
expect(operator).toContain("ConsulKeyringWriteBody { key: request.key.trim() }");
|
||||
expect(operator).not.toMatch(/query_pairs_mut\(\)\.append_pair\([^\n]*request\.key/);
|
||||
});
|
||||
|
||||
it("uses the official write methods for ACL, Enterprise, and Audit endpoints", () => {
|
||||
const acl = source("acl.rs");
|
||||
expect(acl).toContain('request_json(Method::PUT, url, Some(&request), false, "clone ACL token")');
|
||||
expect(acl).toContain("(Method::PUT, collection_create_path(kind)?.to_string())");
|
||||
|
||||
const enterprise = source("enterprise.rs");
|
||||
expect(enterprise).toMatch(/None => \(\s*Method::PUT,/);
|
||||
|
||||
const operator = source("operator.rs");
|
||||
expect(operator).toContain('ConsulOperatorReadKind::Audit => "/v1/operator/audit-hash"');
|
||||
expect(operator).toMatch(/matches!\(kind, ConsulOperatorReadKind::Audit\)[\s\S]*?request_json\(\s*Method::POST/);
|
||||
});
|
||||
|
||||
it.each(Object.entries(methodScopeSnapshot))("keeps %s method/scope call counts stable", (file, expected) => {
|
||||
const code = source(file).split("#[cfg(test)]")[0];
|
||||
const count = (pattern: RegExp) => code.match(pattern)?.length ?? 0;
|
||||
expect([
|
||||
count(/Method::GET/g),
|
||||
count(/Method::PUT/g),
|
||||
count(/Method::POST/g),
|
||||
count(/Method::DELETE/g),
|
||||
count(/(?:request_json|send_json)\([^;]*?true/g) + count(/append_scope\(&mut url, true/g) + count(/read_list\(/g),
|
||||
count(/(?:request_json|send_json)\([^;]*?false/g) + count(/append_scope\(&mut url, false/g) + count(/request_json_unscoped\(/g),
|
||||
]).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const tauri = readFileSync(new URL("../tauri.ts", import.meta.url), "utf8");
|
||||
const http = readFileSync(new URL("../http.ts", import.meta.url), "utf8");
|
||||
const api = readFileSync(new URL("../api.ts", import.meta.url), "utf8");
|
||||
const tauriRegistry = readFileSync(new URL("../../../../../../src-tauri/src/lib.rs", import.meta.url), "utf8");
|
||||
const webRegistry = readFileSync(new URL("../../../../../../crates/dbx-web/src/main.rs", import.meta.url), "utf8");
|
||||
const operations = [...api.matchAll(/export const (consul\w+) = forward/g)].map((match) => match[1]);
|
||||
const expectedOperations = [
|
||||
"consulCapabilities",
|
||||
"consulTxn",
|
||||
"consulRenameKey",
|
||||
"consulBlockingQuery",
|
||||
"consulDomainWatch",
|
||||
"consulCancelBlocking",
|
||||
"consulWatchStart",
|
||||
"consulListRecursive",
|
||||
"consulSearch",
|
||||
"consulSearchProgress",
|
||||
"consulCancelSearch",
|
||||
"consulExportBundle",
|
||||
"consulImportPreview",
|
||||
"consulImportExecute",
|
||||
"consulDeletePrefixPreview",
|
||||
"consulDeletePrefixExecute",
|
||||
"consulListPrefix",
|
||||
"consulGet",
|
||||
"consulPut",
|
||||
"consulDelete",
|
||||
"consulPreparedQueryList",
|
||||
"consulPreparedQueryRead",
|
||||
"consulPreparedQueryCreate",
|
||||
"consulPreparedQueryUpdate",
|
||||
"consulPreparedQueryDelete",
|
||||
"consulPreparedQueryExecute",
|
||||
"consulPreparedQueryExplain",
|
||||
"consulEventList",
|
||||
"consulEventFire",
|
||||
"consulCoordinateNodes",
|
||||
"consulOperatorRead",
|
||||
"consulSnapshotGenerate",
|
||||
"consulSnapshotRestore",
|
||||
"consulAutopilotUpdate",
|
||||
"consulRaftTransfer",
|
||||
"consulRaftRemove",
|
||||
"consulKeyringWrite",
|
||||
"consulLicenseWrite",
|
||||
"consulStatusLeader",
|
||||
"consulStatusPeers",
|
||||
"consulAgentSelf",
|
||||
"consulAgentMembers",
|
||||
"consulAgentMetrics",
|
||||
"consulCatalogDatacenters",
|
||||
"consulCatalogNodes",
|
||||
"consulCatalogServices",
|
||||
"consulCatalogServiceNodes",
|
||||
"consulCatalogNodeServices",
|
||||
"consulHealthNode",
|
||||
"consulHealthChecks",
|
||||
"consulHealthService",
|
||||
"consulHealthState",
|
||||
"consulAgentServices",
|
||||
"consulAgentService",
|
||||
"consulAgentChecks",
|
||||
"consulAgentRegisterService",
|
||||
"consulAgentDeregisterService",
|
||||
"consulAgentServiceMaintenance",
|
||||
"consulAgentRegisterCheck",
|
||||
"consulAgentDeregisterCheck",
|
||||
"consulAgentUpdateTtl",
|
||||
"consulSessions",
|
||||
"consulNodeSessions",
|
||||
"consulSession",
|
||||
"consulSessionKeys",
|
||||
"consulSessionDestroyImpact",
|
||||
"consulCreateSession",
|
||||
"consulRenewSession",
|
||||
"consulDestroySession",
|
||||
"consulAcquireLock",
|
||||
"consulReleaseLock",
|
||||
"consulAclList",
|
||||
"consulAclTokenSelf",
|
||||
"consulAclTokenClone",
|
||||
"consulAclGet",
|
||||
"consulAclApply",
|
||||
"consulAclReferences",
|
||||
"consulAclDelete",
|
||||
"consulEnterpriseList",
|
||||
"consulEnterpriseGet",
|
||||
"consulEnterpriseApply",
|
||||
"consulEnterpriseImpact",
|
||||
"consulEnterpriseDelete",
|
||||
"consulMeshConfigList",
|
||||
"consulMeshConfigGet",
|
||||
"consulMeshConfigApply",
|
||||
"consulMeshConfigDelete",
|
||||
"consulMeshIntentionsList",
|
||||
"consulMeshIntentionGet",
|
||||
"consulMeshIntentionGetExact",
|
||||
"consulMeshIntentionUpsert",
|
||||
"consulMeshIntentionDelete",
|
||||
"consulMeshIntentionDeleteExact",
|
||||
"consulMeshIntentionMatch",
|
||||
"consulMeshIntentionCheck",
|
||||
"consulMeshDiscoveryChain",
|
||||
"consulMeshPeeringList",
|
||||
"consulMeshPeeringGet",
|
||||
"consulMeshPeeringGenerateToken",
|
||||
"consulMeshPeeringEstablish",
|
||||
"consulMeshPeeringDelete",
|
||||
"consulMeshExportedServicesList",
|
||||
"consulMeshExportedServicesApply",
|
||||
] as const;
|
||||
|
||||
function functionBody(source: string, operation: string): string {
|
||||
const start = source.indexOf(`export async function ${operation}(`);
|
||||
expect(start, `${operation} transport function`).toBeGreaterThanOrEqual(0);
|
||||
const end = source.indexOf("\nexport async function ", start + 1);
|
||||
return source.slice(start, end === -1 ? source.length : end);
|
||||
}
|
||||
|
||||
function snakeCase(value: string): string {
|
||||
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
describe("Consul dual transport contract", () => {
|
||||
it.each(operations)("exports %s in Tauri and Web transports", (operation) => {
|
||||
expect(tauri).toContain(`function ${operation}`);
|
||||
expect(http).toContain(`function ${operation}`);
|
||||
});
|
||||
|
||||
it("covers every public Consul operation", () => {
|
||||
expect(operations).toEqual(expectedOperations);
|
||||
expect(new Set(operations).size).toBe(operations.length);
|
||||
});
|
||||
|
||||
it.each(expectedOperations)("registers %s end-to-end", (operation) => {
|
||||
const command = functionBody(tauri, operation).match(/invoke\("([^"]+)"/)?.[1];
|
||||
const httpBody = functionBody(http, operation);
|
||||
const route = httpBody.match(/post\("([^"]+)"/)?.[1] ?? (operation === "consulWatchStart" && httpBody.includes("consulBlockingQuery") ? "/api/consul/blocking-query" : undefined);
|
||||
expect(command, `${operation} invoke command`).toBe(snakeCase(operation));
|
||||
expect(route, `${operation} HTTP route`).toMatch(/^\/api\/consul\//);
|
||||
expect(tauriRegistry).toContain(`commands::consul_cmd::${command},`);
|
||||
expect(webRegistry).toContain(`.route("${route?.replace(/^\/api/, "")}", post(`);
|
||||
});
|
||||
|
||||
it("routes cancellable Catalog and Health watches through both transports", () => {
|
||||
expect(tauri).toContain('invoke("consul_domain_watch"');
|
||||
expect(http).toContain('post("/api/consul/domain-watch"');
|
||||
expect(api).toContain('consulDomainWatch = forward("consulDomainWatch")');
|
||||
});
|
||||
|
||||
it("exposes the Agent service detail endpoint in both transports", () => {
|
||||
expect(tauri).toContain('invoke("consul_agent_service"');
|
||||
expect(http).toContain('post("/api/consul/agent/service"');
|
||||
expect(api).toContain('consulAgentService = forward("consulAgentService")');
|
||||
});
|
||||
|
||||
it("keeps advanced domains asynchronously split", () => {
|
||||
const workspace = readFileSync(new URL("../../../components/consul/ConsulWorkspace.vue", import.meta.url), "utf8");
|
||||
for (const component of ["ConsulKeyBrowser", "ConsulServices", "ConsulHealth", "ConsulSessions", "ConsulAcl", "ConsulScope", "ConsulMesh", "ConsulTools", "ConsulOperator"]) {
|
||||
expect(workspace).toContain(`const ${component} = defineAsyncComponent`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -99,4 +99,19 @@ describe("debug log local timestamps", () => {
|
||||
expect(text).toContain(`[${timestamp}] [INFO] hello`);
|
||||
expect(text).not.toContain(".882Z");
|
||||
});
|
||||
|
||||
it("redacts Consul and nested identity-provider secrets", () => {
|
||||
localStorage.setItem(DEBUG_LOG_ENABLED_KEY, "1");
|
||||
appendDebugLog("error", {
|
||||
SecretID: "acl-secret",
|
||||
peering_token: "peering-secret",
|
||||
nested: { OIDCClientSecret: "oidc-secret", safe: "visible" },
|
||||
});
|
||||
|
||||
const text = getDebugLogText();
|
||||
expect(text).not.toContain("acl-secret");
|
||||
expect(text).not.toContain("peering-secret");
|
||||
expect(text).not.toContain("oidc-secret");
|
||||
expect(text).toContain("visible");
|
||||
});
|
||||
});
|
||||
@@ -470,6 +470,111 @@ export const zookeeperGet = forward("zookeeperGet");
|
||||
export const zookeeperPut = forward("zookeeperPut");
|
||||
export const zookeeperDelete = forward("zookeeperDelete");
|
||||
|
||||
// Consul KV
|
||||
export const consulCapabilities = forward("consulCapabilities");
|
||||
export const consulTxn = forward("consulTxn");
|
||||
export const consulRenameKey = forward("consulRenameKey");
|
||||
export const consulBlockingQuery = forward("consulBlockingQuery");
|
||||
export const consulDomainWatch = forward("consulDomainWatch");
|
||||
export const consulCancelBlocking = forward("consulCancelBlocking");
|
||||
export const consulWatchStart = forward("consulWatchStart");
|
||||
export const consulListRecursive = forward("consulListRecursive");
|
||||
export const consulSearch = forward("consulSearch");
|
||||
export const consulSearchProgress = forward("consulSearchProgress");
|
||||
export const consulCancelSearch = forward("consulCancelSearch");
|
||||
export const consulExportBundle = forward("consulExportBundle");
|
||||
export const consulImportPreview = forward("consulImportPreview");
|
||||
export const consulImportExecute = forward("consulImportExecute");
|
||||
export const consulDeletePrefixPreview = forward("consulDeletePrefixPreview");
|
||||
export const consulDeletePrefixExecute = forward("consulDeletePrefixExecute");
|
||||
export const consulListPrefix = forward("consulListPrefix");
|
||||
export const consulGet = forward("consulGet");
|
||||
export const consulPut = forward("consulPut");
|
||||
export const consulDelete = forward("consulDelete");
|
||||
export const consulPreparedQueryList = forward("consulPreparedQueryList");
|
||||
export const consulPreparedQueryRead = forward("consulPreparedQueryRead");
|
||||
export const consulPreparedQueryCreate = forward("consulPreparedQueryCreate");
|
||||
export const consulPreparedQueryUpdate = forward("consulPreparedQueryUpdate");
|
||||
export const consulPreparedQueryDelete = forward("consulPreparedQueryDelete");
|
||||
export const consulPreparedQueryExecute = forward("consulPreparedQueryExecute");
|
||||
export const consulPreparedQueryExplain = forward("consulPreparedQueryExplain");
|
||||
export const consulEventList = forward("consulEventList");
|
||||
export const consulEventFire = forward("consulEventFire");
|
||||
export const consulCoordinateNodes = forward("consulCoordinateNodes");
|
||||
export const consulOperatorRead = forward("consulOperatorRead");
|
||||
export const consulSnapshotGenerate = forward("consulSnapshotGenerate");
|
||||
export const consulSnapshotRestore = forward("consulSnapshotRestore");
|
||||
export const consulAutopilotUpdate = forward("consulAutopilotUpdate");
|
||||
export const consulRaftTransfer = forward("consulRaftTransfer");
|
||||
export const consulRaftRemove = forward("consulRaftRemove");
|
||||
export const consulKeyringWrite = forward("consulKeyringWrite");
|
||||
export const consulLicenseWrite = forward("consulLicenseWrite");
|
||||
export const consulStatusLeader = forward("consulStatusLeader");
|
||||
export const consulStatusPeers = forward("consulStatusPeers");
|
||||
export const consulAgentSelf = forward("consulAgentSelf");
|
||||
export const consulAgentMembers = forward("consulAgentMembers");
|
||||
export const consulAgentMetrics = forward("consulAgentMetrics");
|
||||
export const consulCatalogDatacenters = forward("consulCatalogDatacenters");
|
||||
export const consulCatalogNodes = forward("consulCatalogNodes");
|
||||
export const consulCatalogServices = forward("consulCatalogServices");
|
||||
export const consulCatalogServiceNodes = forward("consulCatalogServiceNodes");
|
||||
export const consulCatalogNodeServices = forward("consulCatalogNodeServices");
|
||||
export const consulHealthNode = forward("consulHealthNode");
|
||||
export const consulHealthChecks = forward("consulHealthChecks");
|
||||
export const consulHealthService = forward("consulHealthService");
|
||||
export const consulHealthState = forward("consulHealthState");
|
||||
export const consulAgentServices = forward("consulAgentServices");
|
||||
export const consulAgentService = forward("consulAgentService");
|
||||
export const consulAgentChecks = forward("consulAgentChecks");
|
||||
export const consulAgentRegisterService = forward("consulAgentRegisterService");
|
||||
export const consulAgentDeregisterService = forward("consulAgentDeregisterService");
|
||||
export const consulAgentServiceMaintenance = forward("consulAgentServiceMaintenance");
|
||||
export const consulAgentRegisterCheck = forward("consulAgentRegisterCheck");
|
||||
export const consulAgentDeregisterCheck = forward("consulAgentDeregisterCheck");
|
||||
export const consulAgentUpdateTtl = forward("consulAgentUpdateTtl");
|
||||
export const consulSessions = forward("consulSessions");
|
||||
export const consulNodeSessions = forward("consulNodeSessions");
|
||||
export const consulSession = forward("consulSession");
|
||||
export const consulSessionKeys = forward("consulSessionKeys");
|
||||
export const consulSessionDestroyImpact = forward("consulSessionDestroyImpact");
|
||||
export const consulCreateSession = forward("consulCreateSession");
|
||||
export const consulRenewSession = forward("consulRenewSession");
|
||||
export const consulDestroySession = forward("consulDestroySession");
|
||||
export const consulAcquireLock = forward("consulAcquireLock");
|
||||
export const consulReleaseLock = forward("consulReleaseLock");
|
||||
export const consulAclList = forward("consulAclList");
|
||||
export const consulAclTokenSelf = forward("consulAclTokenSelf");
|
||||
export const consulAclTokenClone = forward("consulAclTokenClone");
|
||||
export const consulAclGet = forward("consulAclGet");
|
||||
export const consulAclApply = forward("consulAclApply");
|
||||
export const consulAclReferences = forward("consulAclReferences");
|
||||
export const consulAclDelete = forward("consulAclDelete");
|
||||
export const consulEnterpriseList = forward("consulEnterpriseList");
|
||||
export const consulEnterpriseGet = forward("consulEnterpriseGet");
|
||||
export const consulEnterpriseApply = forward("consulEnterpriseApply");
|
||||
export const consulEnterpriseImpact = forward("consulEnterpriseImpact");
|
||||
export const consulEnterpriseDelete = forward("consulEnterpriseDelete");
|
||||
export const consulMeshConfigList = forward("consulMeshConfigList");
|
||||
export const consulMeshConfigGet = forward("consulMeshConfigGet");
|
||||
export const consulMeshConfigApply = forward("consulMeshConfigApply");
|
||||
export const consulMeshConfigDelete = forward("consulMeshConfigDelete");
|
||||
export const consulMeshIntentionsList = forward("consulMeshIntentionsList");
|
||||
export const consulMeshIntentionGet = forward("consulMeshIntentionGet");
|
||||
export const consulMeshIntentionGetExact = forward("consulMeshIntentionGetExact");
|
||||
export const consulMeshIntentionUpsert = forward("consulMeshIntentionUpsert");
|
||||
export const consulMeshIntentionDelete = forward("consulMeshIntentionDelete");
|
||||
export const consulMeshIntentionDeleteExact = forward("consulMeshIntentionDeleteExact");
|
||||
export const consulMeshIntentionMatch = forward("consulMeshIntentionMatch");
|
||||
export const consulMeshIntentionCheck = forward("consulMeshIntentionCheck");
|
||||
export const consulMeshDiscoveryChain = forward("consulMeshDiscoveryChain");
|
||||
export const consulMeshPeeringList = forward("consulMeshPeeringList");
|
||||
export const consulMeshPeeringGet = forward("consulMeshPeeringGet");
|
||||
export const consulMeshPeeringGenerateToken = forward("consulMeshPeeringGenerateToken");
|
||||
export const consulMeshPeeringEstablish = forward("consulMeshPeeringEstablish");
|
||||
export const consulMeshPeeringDelete = forward("consulMeshPeeringDelete");
|
||||
export const consulMeshExportedServicesList = forward("consulMeshExportedServicesList");
|
||||
export const consulMeshExportedServicesApply = forward("consulMeshExportedServicesApply");
|
||||
|
||||
// HBase
|
||||
export const hbaseGetTableSchema = forward("hbaseGetTableSchema");
|
||||
export const hbaseScanRows = forward("hbaseScanRows");
|
||||
|
||||
@@ -53,7 +53,7 @@ function readEntries(): DebugLogEntry[] {
|
||||
}
|
||||
|
||||
function redactSensitiveText(value: string): string {
|
||||
return value.replace(/("(?:password|passphrase|apiKey|api_key|token|secret|connectionString|connection_string)"\s*:\s*")([^"]*)(")/gi, "$1[redacted]$3").replace(/\b(password|passphrase|apiKey|api_key|token|secret)=([^&\s]+)/gi, "$1=[redacted]");
|
||||
return value.replace(/("[^"]*(?:password|passphrase|api[_-]?key|token|secret|jwt|connection[_-]?string)[^"]*"\s*:\s*")([^"]*)(")/gi, "$1[redacted]$3").replace(/\b([^=&\s]*(?:password|passphrase|api[_-]?key|token|secret|jwt)[^=&\s]*)=([^&\s]+)/gi, "$1=[redacted]");
|
||||
}
|
||||
|
||||
function formatValue(value: unknown, seen = new WeakSet<object>()): string {
|
||||
|
||||
@@ -2706,6 +2706,355 @@ export async function zookeeperDelete(connectionId: string, key: string): Promis
|
||||
return post("/api/zookeeper/delete", { connectionId, key });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consul
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function consulCapabilities(connectionId: string): Promise<import("@/types/consul").ConsulCapabilities> {
|
||||
return post("/api/consul/capabilities", { connectionId });
|
||||
}
|
||||
|
||||
export async function consulTxn(connectionId: string, request: import("@/types/consul").ConsulTxnRequest): Promise<import("@/types/consul").ConsulTxnResult> {
|
||||
return post("/api/consul/txn", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulRenameKey(connectionId: string, source: string, target: string, expectedModifyIndex: KvInt64, copy = false): Promise<import("@/types/consul").ConsulTxnResult> {
|
||||
return post("/api/consul/rename-key", { connectionId, source, target, expectedModifyIndex, copy });
|
||||
}
|
||||
|
||||
export async function consulBlockingQuery(connectionId: string, request: import("@/types/consul").ConsulBlockingRequest): Promise<import("@/types/consul").ConsulBlockingResponse> {
|
||||
return post("/api/consul/blocking-query", { connectionId, request });
|
||||
}
|
||||
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogServices" } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<Record<string, string[]>>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogNodes" } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulCatalogNode[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogServiceNodes"; service: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulCatalogServiceNode[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogNodeServices"; node: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulNodeServices>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthNode"; node: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthServiceChecks"; service: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(
|
||||
connectionId: string,
|
||||
request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthServiceInstances"; service: string; passing: boolean | null } },
|
||||
): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulServiceInstance[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthState"; state: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulDomainWatchItems>>;
|
||||
export async function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulDomainWatchItems>> {
|
||||
return post("/api/consul/domain-watch", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulCancelBlocking(connectionId: string, scope: import("@/types/consul").ConsulScope, generation: number, operationId: string): Promise<boolean> {
|
||||
return post("/api/consul/cancel-blocking", { connectionId, scope, generation, operationId });
|
||||
}
|
||||
|
||||
export async function consulWatchStart(connectionId: string, request: import("@/types/consul").ConsulBlockingRequest): Promise<string> {
|
||||
await consulBlockingQuery(connectionId, request);
|
||||
return request.operationId;
|
||||
}
|
||||
|
||||
export async function consulListRecursive(connectionId: string, prefix: string, maxEntries = 10_000, maxValueBytes = 32 * 1024 * 1024): Promise<import("@/types/consul").ConsulRecursiveListResponse> {
|
||||
return post("/api/consul/list-recursive", { connectionId, prefix, maxEntries, maxValueBytes });
|
||||
}
|
||||
|
||||
export async function consulSearch(connectionId: string, request: import("@/types/consul").ConsulSearchRequest): Promise<import("@/types/consul").ConsulSearchResponse> {
|
||||
return post("/api/consul/search", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulSearchProgress(connectionId: string, requestId: string, scope: import("@/types/consul").ConsulScope, generation: number): Promise<import("@/types/consul").ConsulSearchProgress> {
|
||||
return post("/api/consul/search-progress", { connectionId, requestId, scope, generation });
|
||||
}
|
||||
|
||||
export async function consulCancelSearch(connectionId: string, requestId: string, scope: import("@/types/consul").ConsulScope, generation: number): Promise<boolean> {
|
||||
return post("/api/consul/cancel-search", { connectionId, requestId, scope, generation });
|
||||
}
|
||||
|
||||
export async function consulExportBundle(connectionId: string, request: import("@/types/consul").ConsulExportRequest): Promise<import("@/types/consul").ConsulKvBundle> {
|
||||
return post("/api/consul/export-bundle", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulImportPreview(connectionId: string, request: import("@/types/consul").ConsulImportRequest): Promise<import("@/types/consul").ConsulImportPreview> {
|
||||
return post("/api/consul/import-preview", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulImportExecute(connectionId: string, request: import("@/types/consul").ConsulImportRequest): Promise<import("@/types/consul").ConsulImportReport> {
|
||||
return post("/api/consul/import-execute", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulDeletePrefixPreview(connectionId: string, prefix: string): Promise<import("@/types/consul").ConsulDeletePrefixPreview> {
|
||||
return post("/api/consul/delete-prefix-preview", { connectionId, prefix });
|
||||
}
|
||||
|
||||
export async function consulDeletePrefixExecute(connectionId: string, request: import("@/types/consul").ConsulDeletePrefixRequest): Promise<import("@/types/consul").ConsulDeletePrefixReport> {
|
||||
return post("/api/consul/delete-prefix-execute", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null): Promise<KvListPrefixResponse> {
|
||||
return post("/api/consul/list-prefix", { connectionId, prefix, limit, continuation: continuation ?? null });
|
||||
}
|
||||
|
||||
export async function consulGet(connectionId: string, key: string): Promise<KvGetResponse> {
|
||||
return post("/api/consul/get", { connectionId, key });
|
||||
}
|
||||
|
||||
export async function consulPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | null): Promise<KvPutResponse> {
|
||||
return post("/api/consul/put", { connectionId, key, value, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function consulDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise<KvDeleteResponse> {
|
||||
return post("/api/consul/delete", { connectionId, key, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function consulPreparedQueryList(connectionId: string): Promise<import("@/types/consul").ConsulPreparedQuery[]> {
|
||||
return post("/api/consul/prepared-query/list", { connectionId });
|
||||
}
|
||||
export async function consulPreparedQueryRead(connectionId: string, id: string): Promise<import("@/types/consul").ConsulPreparedQuery> {
|
||||
return post("/api/consul/prepared-query/read", { connectionId, id });
|
||||
}
|
||||
export async function consulPreparedQueryCreate(connectionId: string, input: import("@/types/consul").ConsulPreparedQueryInput): Promise<string> {
|
||||
return post("/api/consul/prepared-query/create", { connectionId, input });
|
||||
}
|
||||
export async function consulPreparedQueryUpdate(connectionId: string, id: string, input: import("@/types/consul").ConsulPreparedQueryInput): Promise<void> {
|
||||
await post("/api/consul/prepared-query/update", { connectionId, id, input });
|
||||
}
|
||||
export async function consulPreparedQueryDelete(connectionId: string, id: string): Promise<void> {
|
||||
await post("/api/consul/prepared-query/delete", { connectionId, id });
|
||||
}
|
||||
export async function consulPreparedQueryExecute(connectionId: string, request: import("@/types/consul").ConsulPreparedQueryExecuteRequest): Promise<import("@/types/consul").ConsulPreparedQueryExecuteResponse> {
|
||||
return post("/api/consul/prepared-query/execute", { connectionId, request });
|
||||
}
|
||||
export async function consulPreparedQueryExplain(connectionId: string, query: string): Promise<unknown> {
|
||||
return post("/api/consul/prepared-query/explain", { connectionId, id: query });
|
||||
}
|
||||
export async function consulEventList(connectionId: string, name?: string | null): Promise<import("@/types/consul").ConsulEvent[]> {
|
||||
return post("/api/consul/event/list", { connectionId, name: name ?? null });
|
||||
}
|
||||
export async function consulEventFire(connectionId: string, request: import("@/types/consul").ConsulEventFireRequest): Promise<import("@/types/consul").ConsulEvent> {
|
||||
return post("/api/consul/event/fire", { connectionId, request });
|
||||
}
|
||||
export async function consulCoordinateNodes(connectionId: string): Promise<import("@/types/consul").ConsulCoordinate[]> {
|
||||
return post("/api/consul/coordinate/nodes", { connectionId });
|
||||
}
|
||||
export async function consulOperatorRead(connectionId: string, kind: import("@/types/consul").ConsulOperatorReadKind): Promise<import("@/types/consul").ConsulOperatorDocument> {
|
||||
return post("/api/consul/operator/read", { connectionId, kind });
|
||||
}
|
||||
export async function consulSnapshotGenerate(connectionId: string): Promise<import("@/types/consul").ConsulSnapshot> {
|
||||
return post("/api/consul/operator/snapshot/generate", { connectionId });
|
||||
}
|
||||
export async function consulSnapshotRestore(connectionId: string, request: import("@/types/consul").ConsulSnapshotRestoreRequest): Promise<void> {
|
||||
await post("/api/consul/operator/snapshot/restore", { connectionId, request });
|
||||
}
|
||||
export async function consulAutopilotUpdate(connectionId: string, update: import("@/types/consul").ConsulAutopilotUpdate, confirmation: string): Promise<void> {
|
||||
await post("/api/consul/operator/autopilot/update", { connectionId, update, confirmation });
|
||||
}
|
||||
export async function consulRaftTransfer(connectionId: string, request: import("@/types/consul").ConsulRaftWriteRequest): Promise<void> {
|
||||
await post("/api/consul/operator/raft/transfer", { connectionId, request });
|
||||
}
|
||||
export async function consulRaftRemove(connectionId: string, request: import("@/types/consul").ConsulRaftWriteRequest): Promise<void> {
|
||||
await post("/api/consul/operator/raft/remove", { connectionId, request });
|
||||
}
|
||||
export async function consulKeyringWrite(connectionId: string, request: import("@/types/consul").ConsulKeyringWriteRequest): Promise<void> {
|
||||
await post("/api/consul/operator/keyring/write", { connectionId, request });
|
||||
}
|
||||
export async function consulLicenseWrite(connectionId: string, request: import("@/types/consul").ConsulLicenseWriteRequest): Promise<void> {
|
||||
await post("/api/consul/operator/license/write", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulStatusLeader(connectionId: string): Promise<string> {
|
||||
return post("/api/consul/status/leader", { connectionId });
|
||||
}
|
||||
export async function consulStatusPeers(connectionId: string): Promise<string[]> {
|
||||
return post("/api/consul/status/peers", { connectionId });
|
||||
}
|
||||
export async function consulAgentSelf(connectionId: string): Promise<import("@/types/consul").ConsulAgentIdentity> {
|
||||
return post("/api/consul/agent/self", { connectionId });
|
||||
}
|
||||
export async function consulAgentMembers(connectionId: string, wan = false, segment?: string | null): Promise<import("@/types/consul").ConsulAgentMember[]> {
|
||||
return post("/api/consul/agent/members", { connectionId, wan, segment: segment ?? null });
|
||||
}
|
||||
export async function consulAgentMetrics(connectionId: string): Promise<unknown> {
|
||||
return post("/api/consul/agent/metrics", { connectionId });
|
||||
}
|
||||
export async function consulCatalogDatacenters(connectionId: string): Promise<string[]> {
|
||||
return post("/api/consul/catalog/datacenters", { connectionId });
|
||||
}
|
||||
export async function consulCatalogNodes(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulCatalogNode[]>> {
|
||||
return post("/api/consul/catalog/nodes", { connectionId, options });
|
||||
}
|
||||
export async function consulCatalogServices(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<Record<string, string[]>>> {
|
||||
return post("/api/consul/catalog/services", { connectionId, options });
|
||||
}
|
||||
export async function consulCatalogServiceNodes(connectionId: string, service: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulCatalogServiceNode[]>> {
|
||||
return post("/api/consul/catalog/service-nodes", { connectionId, name: service, options });
|
||||
}
|
||||
export async function consulCatalogNodeServices(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulNodeServices>> {
|
||||
return post("/api/consul/catalog/node-services", { connectionId, name: node, options });
|
||||
}
|
||||
export async function consulHealthNode(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return post("/api/consul/health/node", { connectionId, name: node, options });
|
||||
}
|
||||
export async function consulHealthChecks(connectionId: string, service: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return post("/api/consul/health/checks", { connectionId, name: service, options });
|
||||
}
|
||||
export async function consulHealthService(connectionId: string, service: string, passing: boolean | null = null, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulServiceInstance[]>> {
|
||||
return post("/api/consul/health/service", { connectionId, name: service, passing, options });
|
||||
}
|
||||
export async function consulHealthState(connectionId: string, healthState: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return post("/api/consul/health/state", { connectionId, name: healthState, options });
|
||||
}
|
||||
export async function consulAgentServices(connectionId: string): Promise<Record<string, import("@/types/consul").ConsulAgentService>> {
|
||||
return post("/api/consul/agent/services", { connectionId });
|
||||
}
|
||||
export async function consulAgentService(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentService> {
|
||||
return post("/api/consul/agent/service", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentChecks(connectionId: string): Promise<Record<string, import("@/types/consul").ConsulHealthCheck>> {
|
||||
return post("/api/consul/agent/checks", { connectionId });
|
||||
}
|
||||
export async function consulAgentRegisterService(connectionId: string, registration: import("@/types/consul").ConsulAgentServiceRegistration): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/service/register", { connectionId, registration });
|
||||
}
|
||||
export async function consulAgentDeregisterService(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/service/deregister", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentServiceMaintenance(connectionId: string, id: string, enable: boolean, reason?: string | null): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/service/maintenance", { connectionId, id, enable, reason: reason ?? null });
|
||||
}
|
||||
export async function consulAgentRegisterCheck(connectionId: string, registration: import("@/types/consul").ConsulAgentCheckRegistration): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/check/register", { connectionId, registration });
|
||||
}
|
||||
export async function consulAgentDeregisterCheck(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/check/deregister", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentUpdateTtl(connectionId: string, id: string, status: import("@/types/consul").ConsulCheckStatus, output?: string | null): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/check/ttl", { connectionId, id, status, output: output ?? null });
|
||||
}
|
||||
export async function consulSessions(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulSession[]>> {
|
||||
return post("/api/consul/sessions", { connectionId, options });
|
||||
}
|
||||
export async function consulNodeSessions(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulSession[]>> {
|
||||
return post("/api/consul/sessions/node", { connectionId, name: node, options });
|
||||
}
|
||||
export async function consulSession(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSession | null> {
|
||||
return post("/api/consul/session", { connectionId, id });
|
||||
}
|
||||
export async function consulSessionKeys(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSessionKeysResponse> {
|
||||
return post("/api/consul/session/keys", { connectionId, id });
|
||||
}
|
||||
export async function consulSessionDestroyImpact(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSessionDestroyImpact> {
|
||||
return post("/api/consul/session/destroy-impact", { connectionId, id });
|
||||
}
|
||||
export async function consulCreateSession(connectionId: string, request: import("@/types/consul").ConsulSessionCreateRequest): Promise<import("@/types/consul").ConsulSession> {
|
||||
return post("/api/consul/session/create", { connectionId, request });
|
||||
}
|
||||
export async function consulRenewSession(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSession> {
|
||||
return post("/api/consul/session/renew", { connectionId, id });
|
||||
}
|
||||
export async function consulDestroySession(connectionId: string, request: import("@/types/consul").ConsulSessionDestroyRequest): Promise<boolean> {
|
||||
return post("/api/consul/session/destroy", { connectionId, request });
|
||||
}
|
||||
export async function consulAcquireLock(connectionId: string, request: import("@/types/consul").ConsulLockRequest): Promise<import("@/types/consul").ConsulLockResponse> {
|
||||
return post("/api/consul/lock/acquire", { connectionId, request });
|
||||
}
|
||||
export async function consulReleaseLock(connectionId: string, key: string, session: string): Promise<import("@/types/consul").ConsulLockResponse> {
|
||||
return post("/api/consul/lock/release", { connectionId, key, session });
|
||||
}
|
||||
|
||||
export async function consulAclList(connectionId: string, kind: import("@/types/consul").ConsulAclKind): Promise<import("@/types/consul").ConsulAclList> {
|
||||
return post("/api/consul/acl/list", { connectionId, kind });
|
||||
}
|
||||
export async function consulAclTokenSelf(connectionId: string): Promise<import("@/types/consul").ConsulAclToken> {
|
||||
return post("/api/consul/acl/token/self", { connectionId });
|
||||
}
|
||||
export async function consulAclTokenClone(connectionId: string, accessorId: string, description: string): Promise<import("@/types/consul").ConsulAclToken> {
|
||||
return post("/api/consul/acl/token/clone", { connectionId, id: accessorId, description });
|
||||
}
|
||||
export async function consulAclGet(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclItem> {
|
||||
return post("/api/consul/acl/get", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulAclApply(connectionId: string, id: string | null, value: import("@/types/consul").ConsulAclWrite): Promise<import("@/types/consul").ConsulAclItem> {
|
||||
return post("/api/consul/acl/apply", { connectionId, id, value });
|
||||
}
|
||||
export async function consulAclReferences(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclReferences> {
|
||||
return post("/api/consul/acl/references", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulAclDelete(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclReferences> {
|
||||
return post("/api/consul/acl/delete", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulEnterpriseList(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind): Promise<import("@/types/consul").ConsulEnterpriseList> {
|
||||
return post("/api/consul/enterprise/list", { connectionId, kind });
|
||||
}
|
||||
export async function consulEnterpriseGet(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulEnterpriseItem> {
|
||||
return post("/api/consul/enterprise/get", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulEnterpriseApply(connectionId: string, existingName: string | null, item: import("@/types/consul").ConsulEnterpriseWrite): Promise<import("@/types/consul").ConsulEnterpriseItem> {
|
||||
return post("/api/consul/enterprise/apply", { connectionId, existingName, item });
|
||||
}
|
||||
export async function consulEnterpriseImpact(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulScopeImpact> {
|
||||
return post("/api/consul/enterprise/impact", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulEnterpriseDelete(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulScopeImpact> {
|
||||
return post("/api/consul/enterprise/delete", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulMeshConfigList(connectionId: string, kind: string): Promise<import("@/types/consul").ConsulConfigEntry[]> {
|
||||
return post("/api/consul/mesh/config/list", { connectionId, kind });
|
||||
}
|
||||
export async function consulMeshConfigGet(connectionId: string, kind: string, name: string): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return post("/api/consul/mesh/config/get", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulMeshConfigApply(connectionId: string, request: import("@/types/consul").ConsulConfigEntryApply): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return post("/api/consul/mesh/config/apply", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshConfigDelete(connectionId: string, kind: string, name: string, expectedModifyIndex: number): Promise<boolean> {
|
||||
return post("/api/consul/mesh/config/delete", { connectionId, kind, name, expectedModifyIndex });
|
||||
}
|
||||
export async function consulMeshIntentionsList(connectionId: string): Promise<import("@/types/consul").ConsulIntention[]> {
|
||||
return post("/api/consul/mesh/intentions/list", { connectionId });
|
||||
}
|
||||
export async function consulMeshIntentionGet(connectionId: string, id: string): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return post("/api/consul/mesh/intentions/get", { connectionId, id });
|
||||
}
|
||||
export async function consulMeshIntentionGetExact(connectionId: string, request: import("@/types/consul").ConsulIntentionExactRequest): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return post("/api/consul/mesh/intentions/get-exact", { connectionId, exactRequest: request });
|
||||
}
|
||||
export async function consulMeshIntentionUpsert(connectionId: string, item: import("@/types/consul").ConsulIntention): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return post("/api/consul/mesh/intentions/upsert", { connectionId, intention: item });
|
||||
}
|
||||
export async function consulMeshIntentionDelete(connectionId: string, id: string): Promise<boolean> {
|
||||
return post("/api/consul/mesh/intentions/delete", { connectionId, id });
|
||||
}
|
||||
export async function consulMeshIntentionDeleteExact(connectionId: string, request: import("@/types/consul").ConsulIntentionExactRequest): Promise<boolean> {
|
||||
return post("/api/consul/mesh/intentions/delete-exact", { connectionId, exactRequest: request });
|
||||
}
|
||||
export async function consulMeshIntentionMatch(connectionId: string, request: import("@/types/consul").ConsulIntentionMatchRequest): Promise<import("@/types/consul").ConsulIntention[]> {
|
||||
return post("/api/consul/mesh/intentions/match", { connectionId, matchRequest: request });
|
||||
}
|
||||
export async function consulMeshIntentionCheck(connectionId: string, request: import("@/types/consul").ConsulIntentionCheckRequest): Promise<import("@/types/consul").ConsulIntentionCheckResponse> {
|
||||
return post("/api/consul/mesh/intentions/check", { connectionId, checkRequest: request });
|
||||
}
|
||||
export async function consulMeshDiscoveryChain(connectionId: string, service: string): Promise<import("@/types/consul").ConsulDiscoveryChain> {
|
||||
return post("/api/consul/mesh/discovery-chain", { connectionId, service });
|
||||
}
|
||||
export async function consulMeshPeeringList(connectionId: string): Promise<import("@/types/consul").ConsulPeering[]> {
|
||||
return post("/api/consul/mesh/peerings/list", { connectionId });
|
||||
}
|
||||
export async function consulMeshPeeringGet(connectionId: string, name: string): Promise<import("@/types/consul").ConsulPeering> {
|
||||
return post("/api/consul/mesh/peerings/get", { connectionId, name });
|
||||
}
|
||||
export async function consulMeshPeeringGenerateToken(connectionId: string, request: import("@/types/consul").ConsulPeeringGenerateRequest): Promise<import("@/types/consul").ConsulPeeringToken> {
|
||||
return post("/api/consul/mesh/peerings/generate-token", { connectionId, generateRequest: request });
|
||||
}
|
||||
export async function consulMeshPeeringEstablish(connectionId: string, request: import("@/types/consul").ConsulPeeringEstablishRequest): Promise<import("@/types/consul").ConsulPeering> {
|
||||
return post("/api/consul/mesh/peerings/establish", { connectionId, establishRequest: request });
|
||||
}
|
||||
export async function consulMeshPeeringDelete(connectionId: string, name: string): Promise<boolean> {
|
||||
return post("/api/consul/mesh/peerings/delete", { connectionId, name });
|
||||
}
|
||||
export async function consulMeshExportedServicesList(connectionId: string): Promise<import("@/types/consul").ConsulExportedService[]> {
|
||||
return post("/api/consul/mesh/exported-services/list", { connectionId });
|
||||
}
|
||||
export async function consulMeshExportedServicesApply(connectionId: string, name: string, expectedModifyIndex: number, raw: Record<string, unknown>): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return post("/api/consul/mesh/exported-services/apply", { connectionId, name, expectedModifyIndex, raw });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nacos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2404,6 +2404,9 @@ export interface KvKeyMetadata {
|
||||
ephemeralOwner?: number | null;
|
||||
dataLength?: number | null;
|
||||
numChildren?: number | null;
|
||||
flags?: KvInt64 | number | null;
|
||||
lockIndex?: KvInt64 | number | null;
|
||||
session?: string | null;
|
||||
}
|
||||
|
||||
export interface KvKeySummary extends KvKeyMetadata {
|
||||
@@ -2417,6 +2420,7 @@ export interface KvListPrefixResponse {
|
||||
keys: KvKeySummary[];
|
||||
continuation?: string | null;
|
||||
revision?: KvInt64 | number | null;
|
||||
filteredByAcls?: boolean | null;
|
||||
}
|
||||
|
||||
export interface KvListPrefixOptions {
|
||||
@@ -2460,6 +2464,7 @@ export interface KvPutOptions {
|
||||
keyBytes?: KvValue | null;
|
||||
expectedModRevision?: KvInt64 | null;
|
||||
expectedCreateRevision?: KvInt64 | null;
|
||||
flags?: KvInt64 | null;
|
||||
}
|
||||
|
||||
export interface KvDeleteOptions {
|
||||
@@ -2804,6 +2809,351 @@ export async function zookeeperDelete(connectionId: string, key: string): Promis
|
||||
return invoke("zookeeper_delete", { connectionId, key });
|
||||
}
|
||||
|
||||
// --- Consul KV ---
|
||||
export async function consulCapabilities(connectionId: string): Promise<import("@/types/consul").ConsulCapabilities> {
|
||||
return invoke("consul_capabilities", { connectionId });
|
||||
}
|
||||
|
||||
export async function consulTxn(connectionId: string, request: import("@/types/consul").ConsulTxnRequest): Promise<import("@/types/consul").ConsulTxnResult> {
|
||||
return invoke("consul_txn", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulRenameKey(connectionId: string, source: string, target: string, expectedModifyIndex: KvInt64, copy = false): Promise<import("@/types/consul").ConsulTxnResult> {
|
||||
return invoke("consul_rename_key", { connectionId, source, target, expectedModifyIndex, copy });
|
||||
}
|
||||
|
||||
export async function consulBlockingQuery(connectionId: string, request: import("@/types/consul").ConsulBlockingRequest): Promise<import("@/types/consul").ConsulBlockingResponse> {
|
||||
return invoke("consul_blocking_query", { connectionId, request });
|
||||
}
|
||||
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogServices" } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<Record<string, string[]>>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogNodes" } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulCatalogNode[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogServiceNodes"; service: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulCatalogServiceNode[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogNodeServices"; node: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulNodeServices>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthNode"; node: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthServiceChecks"; service: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(
|
||||
connectionId: string,
|
||||
request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthServiceInstances"; service: string; passing: boolean | null } },
|
||||
): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulServiceInstance[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthState"; state: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulDomainWatchItems>>;
|
||||
export async function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulDomainWatchItems>> {
|
||||
return invoke("consul_domain_watch", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulCancelBlocking(connectionId: string, scope: import("@/types/consul").ConsulScope, generation: number, operationId: string): Promise<boolean> {
|
||||
return invoke("consul_cancel_blocking", { connectionId, scope, generation, operationId });
|
||||
}
|
||||
|
||||
export async function consulWatchStart(connectionId: string, request: import("@/types/consul").ConsulBlockingRequest): Promise<string> {
|
||||
return invoke("consul_watch_start", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulListRecursive(connectionId: string, prefix: string, maxEntries = 10_000, maxValueBytes = 32 * 1024 * 1024): Promise<import("@/types/consul").ConsulRecursiveListResponse> {
|
||||
return invoke("consul_list_recursive", { connectionId, prefix, maxEntries, maxValueBytes });
|
||||
}
|
||||
|
||||
export async function consulSearch(connectionId: string, request: import("@/types/consul").ConsulSearchRequest): Promise<import("@/types/consul").ConsulSearchResponse> {
|
||||
return invoke("consul_search", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulSearchProgress(connectionId: string, requestId: string, scope: import("@/types/consul").ConsulScope, generation: number): Promise<import("@/types/consul").ConsulSearchProgress> {
|
||||
return invoke("consul_search_progress", { connectionId, requestId, scope, generation });
|
||||
}
|
||||
|
||||
export async function consulCancelSearch(connectionId: string, requestId: string, scope: import("@/types/consul").ConsulScope, generation: number): Promise<boolean> {
|
||||
return invoke("consul_cancel_search", { connectionId, requestId, scope, generation });
|
||||
}
|
||||
|
||||
export async function consulExportBundle(connectionId: string, request: import("@/types/consul").ConsulExportRequest): Promise<import("@/types/consul").ConsulKvBundle> {
|
||||
return invoke("consul_export_bundle", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulImportPreview(connectionId: string, request: import("@/types/consul").ConsulImportRequest): Promise<import("@/types/consul").ConsulImportPreview> {
|
||||
return invoke("consul_import_preview", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulImportExecute(connectionId: string, request: import("@/types/consul").ConsulImportRequest): Promise<import("@/types/consul").ConsulImportReport> {
|
||||
return invoke("consul_import_execute", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulDeletePrefixPreview(connectionId: string, prefix: string): Promise<import("@/types/consul").ConsulDeletePrefixPreview> {
|
||||
return invoke("consul_delete_prefix_preview", { connectionId, prefix });
|
||||
}
|
||||
|
||||
export async function consulDeletePrefixExecute(connectionId: string, request: import("@/types/consul").ConsulDeletePrefixRequest): Promise<import("@/types/consul").ConsulDeletePrefixReport> {
|
||||
return invoke("consul_delete_prefix_execute", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null): Promise<KvListPrefixResponse> {
|
||||
return invoke("consul_list_prefix", { connectionId, prefix, limit, continuation: continuation ?? null });
|
||||
}
|
||||
|
||||
export async function consulGet(connectionId: string, key: string): Promise<KvGetResponse> {
|
||||
return invoke("consul_get", { connectionId, key });
|
||||
}
|
||||
|
||||
export async function consulPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | null): Promise<KvPutResponse> {
|
||||
return invoke("consul_put", { connectionId, key, value, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function consulDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise<KvDeleteResponse> {
|
||||
return invoke("consul_delete", { connectionId, key, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function consulPreparedQueryList(connectionId: string): Promise<import("@/types/consul").ConsulPreparedQuery[]> {
|
||||
return invoke("consul_prepared_query_list", { connectionId });
|
||||
}
|
||||
export async function consulPreparedQueryRead(connectionId: string, id: string): Promise<import("@/types/consul").ConsulPreparedQuery> {
|
||||
return invoke("consul_prepared_query_read", { connectionId, id });
|
||||
}
|
||||
export async function consulPreparedQueryCreate(connectionId: string, input: import("@/types/consul").ConsulPreparedQueryInput): Promise<string> {
|
||||
return invoke("consul_prepared_query_create", { connectionId, input });
|
||||
}
|
||||
export async function consulPreparedQueryUpdate(connectionId: string, id: string, input: import("@/types/consul").ConsulPreparedQueryInput): Promise<void> {
|
||||
return invoke("consul_prepared_query_update", { connectionId, id, input });
|
||||
}
|
||||
export async function consulPreparedQueryDelete(connectionId: string, id: string): Promise<void> {
|
||||
return invoke("consul_prepared_query_delete", { connectionId, id });
|
||||
}
|
||||
export async function consulPreparedQueryExecute(connectionId: string, request: import("@/types/consul").ConsulPreparedQueryExecuteRequest): Promise<import("@/types/consul").ConsulPreparedQueryExecuteResponse> {
|
||||
return invoke("consul_prepared_query_execute", { connectionId, request });
|
||||
}
|
||||
export async function consulPreparedQueryExplain(connectionId: string, query: string): Promise<unknown> {
|
||||
return invoke("consul_prepared_query_explain", { connectionId, query });
|
||||
}
|
||||
export async function consulEventList(connectionId: string, name?: string | null): Promise<import("@/types/consul").ConsulEvent[]> {
|
||||
return invoke("consul_event_list", { connectionId, name: name ?? null });
|
||||
}
|
||||
export async function consulEventFire(connectionId: string, request: import("@/types/consul").ConsulEventFireRequest): Promise<import("@/types/consul").ConsulEvent> {
|
||||
return invoke("consul_event_fire", { connectionId, request });
|
||||
}
|
||||
export async function consulCoordinateNodes(connectionId: string): Promise<import("@/types/consul").ConsulCoordinate[]> {
|
||||
return invoke("consul_coordinate_nodes", { connectionId });
|
||||
}
|
||||
export async function consulOperatorRead(connectionId: string, kind: import("@/types/consul").ConsulOperatorReadKind): Promise<import("@/types/consul").ConsulOperatorDocument> {
|
||||
return invoke("consul_operator_read", { connectionId, kind });
|
||||
}
|
||||
export async function consulSnapshotGenerate(connectionId: string): Promise<import("@/types/consul").ConsulSnapshot> {
|
||||
return invoke("consul_snapshot_generate", { connectionId });
|
||||
}
|
||||
export async function consulSnapshotRestore(connectionId: string, request: import("@/types/consul").ConsulSnapshotRestoreRequest): Promise<void> {
|
||||
return invoke("consul_snapshot_restore", { connectionId, request });
|
||||
}
|
||||
export async function consulAutopilotUpdate(connectionId: string, update: import("@/types/consul").ConsulAutopilotUpdate, confirmation: string): Promise<void> {
|
||||
return invoke("consul_autopilot_update", { connectionId, update, confirmation });
|
||||
}
|
||||
export async function consulRaftTransfer(connectionId: string, request: import("@/types/consul").ConsulRaftWriteRequest): Promise<void> {
|
||||
return invoke("consul_raft_transfer", { connectionId, request });
|
||||
}
|
||||
export async function consulRaftRemove(connectionId: string, request: import("@/types/consul").ConsulRaftWriteRequest): Promise<void> {
|
||||
return invoke("consul_raft_remove", { connectionId, request });
|
||||
}
|
||||
export async function consulKeyringWrite(connectionId: string, request: import("@/types/consul").ConsulKeyringWriteRequest): Promise<void> {
|
||||
return invoke("consul_keyring_write", { connectionId, request });
|
||||
}
|
||||
export async function consulLicenseWrite(connectionId: string, request: import("@/types/consul").ConsulLicenseWriteRequest): Promise<void> {
|
||||
return invoke("consul_license_write", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulStatusLeader(connectionId: string): Promise<string> {
|
||||
return invoke("consul_status_leader", { connectionId });
|
||||
}
|
||||
export async function consulStatusPeers(connectionId: string): Promise<string[]> {
|
||||
return invoke("consul_status_peers", { connectionId });
|
||||
}
|
||||
export async function consulAgentSelf(connectionId: string): Promise<import("@/types/consul").ConsulAgentIdentity> {
|
||||
return invoke("consul_agent_self", { connectionId });
|
||||
}
|
||||
export async function consulAgentMembers(connectionId: string, wan = false, segment?: string | null): Promise<import("@/types/consul").ConsulAgentMember[]> {
|
||||
return invoke("consul_agent_members", { connectionId, wan, segment: segment ?? null });
|
||||
}
|
||||
export async function consulAgentMetrics(connectionId: string): Promise<unknown> {
|
||||
return invoke("consul_agent_metrics", { connectionId });
|
||||
}
|
||||
export async function consulCatalogDatacenters(connectionId: string): Promise<string[]> {
|
||||
return invoke("consul_catalog_datacenters", { connectionId });
|
||||
}
|
||||
export async function consulCatalogNodes(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulCatalogNode[]>> {
|
||||
return invoke("consul_catalog_nodes", { connectionId, options });
|
||||
}
|
||||
export async function consulCatalogServices(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<Record<string, string[]>>> {
|
||||
return invoke("consul_catalog_services", { connectionId, options });
|
||||
}
|
||||
export async function consulCatalogServiceNodes(connectionId: string, service: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulCatalogServiceNode[]>> {
|
||||
return invoke("consul_catalog_service_nodes", { connectionId, service, options });
|
||||
}
|
||||
export async function consulCatalogNodeServices(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulNodeServices>> {
|
||||
return invoke("consul_catalog_node_services", { connectionId, node, options });
|
||||
}
|
||||
export async function consulHealthNode(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return invoke("consul_health_node", { connectionId, node, options });
|
||||
}
|
||||
export async function consulHealthChecks(connectionId: string, service: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return invoke("consul_health_checks", { connectionId, service, options });
|
||||
}
|
||||
export async function consulHealthService(connectionId: string, service: string, passing: boolean | null = null, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulServiceInstance[]>> {
|
||||
return invoke("consul_health_service", { connectionId, service, passing, options });
|
||||
}
|
||||
export async function consulHealthState(connectionId: string, healthState: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return invoke("consul_health_state", { connectionId, healthState, options });
|
||||
}
|
||||
export async function consulAgentServices(connectionId: string): Promise<Record<string, import("@/types/consul").ConsulAgentService>> {
|
||||
return invoke("consul_agent_services", { connectionId });
|
||||
}
|
||||
export async function consulAgentService(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentService> {
|
||||
return invoke("consul_agent_service", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentChecks(connectionId: string): Promise<Record<string, import("@/types/consul").ConsulHealthCheck>> {
|
||||
return invoke("consul_agent_checks", { connectionId });
|
||||
}
|
||||
export async function consulAgentRegisterService(connectionId: string, registration: import("@/types/consul").ConsulAgentServiceRegistration): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_register_service", { connectionId, registration });
|
||||
}
|
||||
export async function consulAgentDeregisterService(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_deregister_service", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentServiceMaintenance(connectionId: string, id: string, enable: boolean, reason?: string | null): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_service_maintenance", { connectionId, id, enable, reason: reason ?? null });
|
||||
}
|
||||
export async function consulAgentRegisterCheck(connectionId: string, registration: import("@/types/consul").ConsulAgentCheckRegistration): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_register_check", { connectionId, registration });
|
||||
}
|
||||
export async function consulAgentDeregisterCheck(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_deregister_check", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentUpdateTtl(connectionId: string, id: string, status: import("@/types/consul").ConsulCheckStatus, output?: string | null): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_update_ttl", { connectionId, id, status, output: output ?? null });
|
||||
}
|
||||
export async function consulSessions(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulSession[]>> {
|
||||
return invoke("consul_sessions", { connectionId, options });
|
||||
}
|
||||
export async function consulNodeSessions(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulSession[]>> {
|
||||
return invoke("consul_node_sessions", { connectionId, node, options });
|
||||
}
|
||||
export async function consulSession(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSession | null> {
|
||||
return invoke("consul_session", { connectionId, id });
|
||||
}
|
||||
export async function consulSessionKeys(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSessionKeysResponse> {
|
||||
return invoke("consul_session_keys", { connectionId, id });
|
||||
}
|
||||
export async function consulSessionDestroyImpact(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSessionDestroyImpact> {
|
||||
return invoke("consul_session_destroy_impact", { connectionId, id });
|
||||
}
|
||||
export async function consulCreateSession(connectionId: string, request: import("@/types/consul").ConsulSessionCreateRequest): Promise<import("@/types/consul").ConsulSession> {
|
||||
return invoke("consul_create_session", { connectionId, request });
|
||||
}
|
||||
export async function consulRenewSession(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSession> {
|
||||
return invoke("consul_renew_session", { connectionId, id });
|
||||
}
|
||||
export async function consulDestroySession(connectionId: string, request: import("@/types/consul").ConsulSessionDestroyRequest): Promise<boolean> {
|
||||
return invoke("consul_destroy_session", { connectionId, request });
|
||||
}
|
||||
export async function consulAcquireLock(connectionId: string, request: import("@/types/consul").ConsulLockRequest): Promise<import("@/types/consul").ConsulLockResponse> {
|
||||
return invoke("consul_acquire_lock", { connectionId, request });
|
||||
}
|
||||
export async function consulReleaseLock(connectionId: string, key: string, session: string): Promise<import("@/types/consul").ConsulLockResponse> {
|
||||
return invoke("consul_release_lock", { connectionId, key, session });
|
||||
}
|
||||
|
||||
export async function consulAclList(connectionId: string, kind: import("@/types/consul").ConsulAclKind): Promise<import("@/types/consul").ConsulAclList> {
|
||||
return invoke("consul_acl_list", { connectionId, kind });
|
||||
}
|
||||
export async function consulAclTokenSelf(connectionId: string): Promise<import("@/types/consul").ConsulAclToken> {
|
||||
return invoke("consul_acl_token_self", { connectionId });
|
||||
}
|
||||
export async function consulAclTokenClone(connectionId: string, accessorId: string, description: string): Promise<import("@/types/consul").ConsulAclToken> {
|
||||
return invoke("consul_acl_token_clone", { connectionId, accessorId, request: { Description: description } });
|
||||
}
|
||||
export async function consulAclGet(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclItem> {
|
||||
return invoke("consul_acl_get", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulAclApply(connectionId: string, id: string | null, value: import("@/types/consul").ConsulAclWrite): Promise<import("@/types/consul").ConsulAclItem> {
|
||||
return invoke("consul_acl_apply", { connectionId, id, value });
|
||||
}
|
||||
export async function consulAclReferences(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclReferences> {
|
||||
return invoke("consul_acl_references", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulAclDelete(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclReferences> {
|
||||
return invoke("consul_acl_delete", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulEnterpriseList(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind): Promise<import("@/types/consul").ConsulEnterpriseList> {
|
||||
return invoke("consul_enterprise_list", { connectionId, kind });
|
||||
}
|
||||
export async function consulEnterpriseGet(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulEnterpriseItem> {
|
||||
return invoke("consul_enterprise_get", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulEnterpriseApply(connectionId: string, existingName: string | null, item: import("@/types/consul").ConsulEnterpriseWrite): Promise<import("@/types/consul").ConsulEnterpriseItem> {
|
||||
return invoke("consul_enterprise_apply", { connectionId, existingName, item });
|
||||
}
|
||||
export async function consulEnterpriseImpact(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulScopeImpact> {
|
||||
return invoke("consul_enterprise_impact", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulEnterpriseDelete(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulScopeImpact> {
|
||||
return invoke("consul_enterprise_delete", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulMeshConfigList(connectionId: string, kind: string): Promise<import("@/types/consul").ConsulConfigEntry[]> {
|
||||
return invoke("consul_mesh_config_list", { connectionId, kind });
|
||||
}
|
||||
export async function consulMeshConfigGet(connectionId: string, kind: string, name: string): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return invoke("consul_mesh_config_get", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulMeshConfigApply(connectionId: string, request: import("@/types/consul").ConsulConfigEntryApply): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return invoke("consul_mesh_config_apply", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshConfigDelete(connectionId: string, kind: string, name: string, expectedModifyIndex: number): Promise<boolean> {
|
||||
return invoke("consul_mesh_config_delete", { connectionId, kind, name, expectedModifyIndex });
|
||||
}
|
||||
export async function consulMeshIntentionsList(connectionId: string): Promise<import("@/types/consul").ConsulIntention[]> {
|
||||
return invoke("consul_mesh_intentions_list", { connectionId });
|
||||
}
|
||||
export async function consulMeshIntentionGet(connectionId: string, id: string): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return invoke("consul_mesh_intention_get", { connectionId, id });
|
||||
}
|
||||
export async function consulMeshIntentionGetExact(connectionId: string, request: import("@/types/consul").ConsulIntentionExactRequest): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return invoke("consul_mesh_intention_get_exact", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshIntentionUpsert(connectionId: string, item: import("@/types/consul").ConsulIntention): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return invoke("consul_mesh_intention_upsert", { connectionId, item });
|
||||
}
|
||||
export async function consulMeshIntentionDelete(connectionId: string, id: string): Promise<boolean> {
|
||||
return invoke("consul_mesh_intention_delete", { connectionId, id });
|
||||
}
|
||||
export async function consulMeshIntentionDeleteExact(connectionId: string, request: import("@/types/consul").ConsulIntentionExactRequest): Promise<boolean> {
|
||||
return invoke("consul_mesh_intention_delete_exact", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshIntentionMatch(connectionId: string, request: import("@/types/consul").ConsulIntentionMatchRequest): Promise<import("@/types/consul").ConsulIntention[]> {
|
||||
return invoke("consul_mesh_intention_match", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshIntentionCheck(connectionId: string, request: import("@/types/consul").ConsulIntentionCheckRequest): Promise<import("@/types/consul").ConsulIntentionCheckResponse> {
|
||||
return invoke("consul_mesh_intention_check", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshDiscoveryChain(connectionId: string, service: string): Promise<import("@/types/consul").ConsulDiscoveryChain> {
|
||||
return invoke("consul_mesh_discovery_chain", { connectionId, service });
|
||||
}
|
||||
export async function consulMeshPeeringList(connectionId: string): Promise<import("@/types/consul").ConsulPeering[]> {
|
||||
return invoke("consul_mesh_peering_list", { connectionId });
|
||||
}
|
||||
export async function consulMeshPeeringGet(connectionId: string, name: string): Promise<import("@/types/consul").ConsulPeering> {
|
||||
return invoke("consul_mesh_peering_get", { connectionId, name });
|
||||
}
|
||||
export async function consulMeshPeeringGenerateToken(connectionId: string, request: import("@/types/consul").ConsulPeeringGenerateRequest): Promise<import("@/types/consul").ConsulPeeringToken> {
|
||||
return invoke("consul_mesh_peering_generate_token", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshPeeringEstablish(connectionId: string, request: import("@/types/consul").ConsulPeeringEstablishRequest): Promise<import("@/types/consul").ConsulPeering> {
|
||||
return invoke("consul_mesh_peering_establish", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshPeeringDelete(connectionId: string, name: string): Promise<boolean> {
|
||||
return invoke("consul_mesh_peering_delete", { connectionId, name });
|
||||
}
|
||||
export async function consulMeshExportedServicesList(connectionId: string): Promise<import("@/types/consul").ConsulExportedService[]> {
|
||||
return invoke("consul_mesh_exported_services_list", { connectionId });
|
||||
}
|
||||
export async function consulMeshExportedServicesApply(connectionId: string, name: string, expectedModifyIndex: number, raw: Record<string, unknown>): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return invoke("consul_mesh_exported_services_apply", { connectionId, name, expectedModifyIndex, raw });
|
||||
}
|
||||
|
||||
// --- HBase ---
|
||||
export async function hbaseGetTableSchema(connectionId: string, namespace: string, table: string): Promise<import("@/types/hbase").HBaseTableSchema> {
|
||||
return invoke("hbase_get_table_schema", { connectionId, namespace, table });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
|
||||
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "nacos-admin" } | { kind: "etcd" } | { kind: "zookeeper" } | { kind: "query"; database: string };
|
||||
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "nacos-admin" } | { kind: "etcd" } | { kind: "zookeeper" } | { kind: "consul" } | { kind: "query"; database: string };
|
||||
|
||||
export function quickConnectionOpenTarget(connection: Pick<ConnectionConfig, "db_type" | "database">, databaseOptions: string[] = []): QuickConnectionOpenTarget {
|
||||
if (connection.db_type === "mq") {
|
||||
@@ -16,5 +16,8 @@ export function quickConnectionOpenTarget(connection: Pick<ConnectionConfig, "db
|
||||
if (connection.db_type === "zookeeper") {
|
||||
return { kind: "zookeeper" };
|
||||
}
|
||||
if (connection.db_type === "consul") {
|
||||
return { kind: "consul" };
|
||||
}
|
||||
return { kind: "query", database: resolveDefaultDatabase(connection, databaseOptions) };
|
||||
}
|
||||
@@ -136,6 +136,7 @@ export function connectionDisplayUrlScheme(connection: Pick<ConnectionConfig, "d
|
||||
case "rqlite":
|
||||
case "turso":
|
||||
case "mq":
|
||||
case "consul":
|
||||
return connection.ssl ? "https" : "http";
|
||||
case "cloudflare-d1":
|
||||
return "https";
|
||||
@@ -171,6 +172,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
|
||||
case "zookeeper":
|
||||
return "zookeeper://host:2181";
|
||||
|
||||
case "consul":
|
||||
return "http://host:8500";
|
||||
|
||||
case "sqlite":
|
||||
return "sqlite:///absolute/path/to/database.db";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
|
||||
|
||||
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
|
||||
// non-database services expose their own root objects rather than database namespaces.
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]);
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
|
||||
type VisibleDatabaseConnectionFields = Pick<
|
||||
ConnectionConfig,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consulAgentAddressesMatch, consulAgentWriteTargetSafe } from "../agentTarget";
|
||||
|
||||
describe("Consul Agent target safety", () => {
|
||||
it("treats loopback aliases as equivalent", () => {
|
||||
expect(consulAgentAddressesMatch("localhost", "127.0.0.1")).toBe(true);
|
||||
expect(consulAgentAddressesMatch("::1", "localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects DNS and Transport targets in the UI gate", () => {
|
||||
const target = { agentTarget: { node: "node-1", address: "127.0.0.1" } };
|
||||
expect(consulAgentWriteTargetSafe({ external_config: { ...target, serverAddr: "https://consul.example" } }, "node-1")).toBe(false);
|
||||
expect(consulAgentWriteTargetSafe({ transport_layers: [{}], external_config: { ...target, serverAddr: "http://127.0.0.1:8500" } }, "node-1")).toBe(false);
|
||||
expect(consulAgentWriteTargetSafe({ external_config: { ...target, serverAddr: "http://localhost:8500" } }, "node-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { capabilityVisible, capabilityWritable, requireConfirmation, scopeHasWildcard } from "@/lib/consul/consulPolicy";
|
||||
import { nextWatchIndex } from "@/lib/consul/watchState";
|
||||
|
||||
describe("Consul domain policies", () => {
|
||||
it("keeps forbidden separate from unsupported", () => {
|
||||
expect(capabilityVisible("forbidden")).toBe(true);
|
||||
expect(capabilityVisible("unsupported")).toBe(false);
|
||||
expect(capabilityWritable("supported", false)).toBe(true);
|
||||
expect(capabilityWritable("supported", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires explicit wildcard scope and exact confirmations", () => {
|
||||
expect(scopeHasWildcard({ datacenter: "dc1", namespace: "*", partition: "default" })).toBe(true);
|
||||
expect(scopeHasWildcard({ datacenter: "dc1", namespace: "team", partition: "default" })).toBe(false);
|
||||
expect(requireConfirmation("DELETE team", "DELETE team")).toBe(true);
|
||||
expect(requireConfirmation("DELETE team", "delete team")).toBe(false);
|
||||
});
|
||||
|
||||
it("resets blocking indexes after zero or rollback", () => {
|
||||
expect(nextWatchIndex("50", "0")).toEqual({ index: "1", reset: true });
|
||||
expect(nextWatchIndex("50", "49")).toEqual({ index: "1", reset: true });
|
||||
expect(nextWatchIndex("50", "51")).toEqual({ index: "51", reset: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consulMeshWorkspaceVisible } from "@/lib/consul/meshVisibility";
|
||||
|
||||
describe("consulMeshWorkspaceVisible", () => {
|
||||
it("keeps the advanced workspace hidden unless explicitly enabled", () => {
|
||||
expect(consulMeshWorkspaceVisible({})).toBe(false);
|
||||
expect(consulMeshWorkspaceVisible(undefined)).toBe(false);
|
||||
expect(consulMeshWorkspaceVisible({ consulMeshVisible: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the workspace when the connection explicitly enables it", () => {
|
||||
expect(consulMeshWorkspaceVisible({ consulMeshVisible: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampConsulPage, consulPageCount, paginateConsulItems } from "@/lib/consul/pagination";
|
||||
|
||||
describe("Consul list pagination", () => {
|
||||
it("splits large result sets without dropping the final partial page", () => {
|
||||
const items = Array.from({ length: 123 }, (_, index) => index + 1);
|
||||
expect(consulPageCount(items.length, 50)).toBe(3);
|
||||
expect(paginateConsulItems(items, 1, 50)).toEqual(items.slice(0, 50));
|
||||
expect(paginateConsulItems(items, 3, 50)).toEqual(items.slice(100));
|
||||
});
|
||||
|
||||
it("clamps stale pages after filtering or deletion", () => {
|
||||
expect(clampConsulPage(9, 51, 50)).toBe(2);
|
||||
expect(clampConsulPage(0, 51, 50)).toBe(1);
|
||||
expect(clampConsulPage(Number.NaN, 0, 50)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeConsulSession } from "../sessionModel";
|
||||
|
||||
describe("Consul Session transport normalization", () => {
|
||||
it("accepts the legacy Checks response during a desktop backend hot restart", () => {
|
||||
const session = normalizeConsulSession({
|
||||
ID: "session-1",
|
||||
Name: "demo",
|
||||
Node: "node-1",
|
||||
LockDelay: 15_000_000_000,
|
||||
Behavior: "release",
|
||||
TTL: "",
|
||||
Checks: ["serfHealth"],
|
||||
CreateIndex: 12,
|
||||
});
|
||||
|
||||
expect(session.NodeChecks).toEqual(["serfHealth"]);
|
||||
expect(session.ServiceChecks).toEqual([]);
|
||||
expect(session.ModifyIndex).toBe(12);
|
||||
});
|
||||
|
||||
it("normalizes nullable Consul 2.0 check collections", () => {
|
||||
const session = normalizeConsulSession({
|
||||
ID: "session-2",
|
||||
NodeChecks: undefined,
|
||||
ServiceChecks: null as never,
|
||||
});
|
||||
|
||||
expect(session.NodeChecks).toEqual([]);
|
||||
expect(session.ServiceChecks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consulSessionRenewDelayMs, parseConsulDurationMs } from "../sessionRenew";
|
||||
|
||||
describe("Consul Session renew timing", () => {
|
||||
it("parses compound Consul durations", () => {
|
||||
expect(parseConsulDurationMs("1m30s")).toBe(90_000);
|
||||
expect(parseConsulDurationMs("250ms")).toBe(250);
|
||||
expect(parseConsulDurationMs("0s")).toBe(0);
|
||||
expect(parseConsulDurationMs("not-a-duration")).toBeNull();
|
||||
});
|
||||
|
||||
it("renews halfway through the TTL within safe timer bounds", () => {
|
||||
expect(consulSessionRenewDelayMs("10s")).toBe(5_000);
|
||||
expect(consulSessionRenewDelayMs("2m")).toBe(30_000);
|
||||
expect(consulSessionRenewDelayMs("250ms")).toBe(1_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCurrentWatchEvent, nextWatchIndex } from "../watchState";
|
||||
import type { ConsulWatchEvent } from "@/types/consul";
|
||||
|
||||
const event: ConsulWatchEvent = {
|
||||
connectionId: "connection-a",
|
||||
operationId: "watch-a",
|
||||
generation: 7,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
describe("Consul watch lifecycle", () => {
|
||||
it("resets zero and regressed indexes to the minimum blocking index", () => {
|
||||
expect(nextWatchIndex("15", "0")).toEqual({ index: "1", reset: true });
|
||||
expect(nextWatchIndex("15", "14")).toEqual({ index: "1", reset: true });
|
||||
expect(nextWatchIndex("15", "16")).toEqual({ index: "16", reset: false });
|
||||
});
|
||||
|
||||
it("rejects events from stale connections, operations, and scope generations", () => {
|
||||
expect(isCurrentWatchEvent({ connectionId: "connection-a", operationId: "watch-a", generation: 7 }, event)).toBe(true);
|
||||
expect(isCurrentWatchEvent({ connectionId: "connection-b", operationId: "watch-a", generation: 7 }, event)).toBe(false);
|
||||
expect(isCurrentWatchEvent({ connectionId: "connection-a", operationId: "watch-b", generation: 7 }, event)).toBe(false);
|
||||
expect(isCurrentWatchEvent({ connectionId: "connection-a", operationId: "watch-a", generation: 8 }, event)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
type AgentTargetConnection = {
|
||||
read_only?: boolean;
|
||||
transport_layers?: Array<{ enabled?: boolean }>;
|
||||
external_config?: unknown;
|
||||
};
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isIpv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
||||
}
|
||||
|
||||
function isIpv6(value: string): boolean {
|
||||
return value.includes(":") && /^[0-9a-f:]+$/i.test(value);
|
||||
}
|
||||
|
||||
function isLoopback(value: string): boolean {
|
||||
const address = normalizeAddress(value);
|
||||
return address === "localhost" || address === "::1" || (isIpv4(address) && address.startsWith("127."));
|
||||
}
|
||||
|
||||
export function consulAgentAddressesMatch(left: string, right: string): boolean {
|
||||
const a = normalizeAddress(left);
|
||||
const b = normalizeAddress(right);
|
||||
return a === b || (a === "localhost" && isLoopback(b)) || (b === "localhost" && isLoopback(a));
|
||||
}
|
||||
|
||||
export function consulAgentWriteTargetSafe(connection: AgentTargetConnection | undefined, identityNode: string | undefined): boolean {
|
||||
if (!connection || connection.read_only || connection.transport_layers?.some((layer) => layer.enabled !== false)) return false;
|
||||
const external = connection.external_config;
|
||||
if (!external || typeof external !== "object" || Array.isArray(external)) return false;
|
||||
const config = external as Record<string, unknown>;
|
||||
const rawTarget = config.agentTarget || config.agent_target;
|
||||
if (!rawTarget || typeof rawTarget !== "object" || Array.isArray(rawTarget)) return false;
|
||||
const target = rawTarget as Record<string, unknown>;
|
||||
const node = String(target.node || "").trim();
|
||||
const address = String(target.address || "").trim();
|
||||
if (!node || !address || node !== identityNode) return false;
|
||||
const serverAddress = String(config.serverAddr || config.server_addr || "").trim();
|
||||
try {
|
||||
const host = normalizeAddress(new URL(serverAddress).hostname);
|
||||
if (host !== "localhost" && !isIpv4(host) && !isIpv6(host)) return false;
|
||||
return consulAgentAddressesMatch(host, address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ConsulCapabilities, ConsulCapabilityStatus, ConsulScope } from "@/types/consul";
|
||||
|
||||
export function capabilityVisible(status: ConsulCapabilityStatus): boolean {
|
||||
return status !== "unsupported" && status !== "disabled";
|
||||
}
|
||||
|
||||
export function capabilityWritable(status: ConsulCapabilityStatus, readonly: boolean): boolean {
|
||||
return !readonly && status === "supported";
|
||||
}
|
||||
|
||||
export function scopeHasWildcard(scope: ConsulScope): boolean {
|
||||
return scope.datacenter === "*" || scope.namespace === "*" || scope.partition === "*";
|
||||
}
|
||||
|
||||
export function capabilityStatus(capabilities: ConsulCapabilities | null, key: keyof ConsulCapabilities): ConsulCapabilityStatus {
|
||||
const value = capabilities?.[key];
|
||||
return value === "supported" || value === "unsupported" || value === "disabled" || value === "forbidden" ? value : "unknown";
|
||||
}
|
||||
|
||||
export function requireConfirmation(expected: string, actual: string): boolean {
|
||||
return expected.length > 0 && actual.trim() === expected;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
function externalConfigRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export function consulMeshWorkspaceVisible(externalConfig: unknown): boolean {
|
||||
const config = externalConfigRecord(externalConfig);
|
||||
return config.consulMeshVisible === true;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CONSUL_LIST_PAGE_SIZE = 50;
|
||||
|
||||
export function consulPageCount(total: number, pageSize = CONSUL_LIST_PAGE_SIZE): number {
|
||||
const safeTotal = Math.max(0, Math.trunc(total));
|
||||
const safePageSize = Math.max(1, Math.trunc(pageSize));
|
||||
return Math.max(1, Math.ceil(safeTotal / safePageSize));
|
||||
}
|
||||
|
||||
export function clampConsulPage(page: number, total: number, pageSize = CONSUL_LIST_PAGE_SIZE): number {
|
||||
const safePage = Number.isFinite(page) ? Math.trunc(page) : 1;
|
||||
return Math.min(Math.max(1, safePage), consulPageCount(total, pageSize));
|
||||
}
|
||||
|
||||
export function paginateConsulItems<T>(items: readonly T[], page: number, pageSize = CONSUL_LIST_PAGE_SIZE): T[] {
|
||||
const safePageSize = Math.max(1, Math.trunc(pageSize));
|
||||
const safePage = clampConsulPage(page, items.length, safePageSize);
|
||||
const start = (safePage - 1) * safePageSize;
|
||||
return items.slice(start, start + safePageSize);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ConsulSession, ConsulSessionServiceCheck } from "@/types/consul";
|
||||
|
||||
type ConsulSessionWire = Partial<ConsulSession> & { Checks?: unknown };
|
||||
|
||||
export function normalizeConsulSession(value: ConsulSessionWire): ConsulSession {
|
||||
const nodeChecks = Array.isArray(value.NodeChecks) ? value.NodeChecks.filter((item): item is string => typeof item === "string") : Array.isArray(value.Checks) ? value.Checks.filter((item): item is string => typeof item === "string") : [];
|
||||
const serviceChecks = Array.isArray(value.ServiceChecks) ? value.ServiceChecks.filter((item): item is ConsulSessionServiceCheck => Boolean(item) && typeof item.ID === "string").map((item) => ({ ID: item.ID, Namespace: typeof item.Namespace === "string" ? item.Namespace : "" })) : [];
|
||||
return {
|
||||
ID: typeof value.ID === "string" ? value.ID : "",
|
||||
Name: typeof value.Name === "string" ? value.Name : "",
|
||||
Node: typeof value.Node === "string" ? value.Node : "",
|
||||
LockDelay: Number(value.LockDelay || 0),
|
||||
Behavior: typeof value.Behavior === "string" ? value.Behavior : "release",
|
||||
TTL: typeof value.TTL === "string" ? value.TTL : "",
|
||||
NodeChecks: nodeChecks,
|
||||
ServiceChecks: serviceChecks,
|
||||
Namespace: typeof value.Namespace === "string" ? value.Namespace : "",
|
||||
Partition: typeof value.Partition === "string" ? value.Partition : "",
|
||||
CreateIndex: Number(value.CreateIndex || 0),
|
||||
ModifyIndex: Number(value.ModifyIndex || value.CreateIndex || 0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const DURATION_PART = /(\d+(?:\.\d+)?)(ms|s|m|h)/gy;
|
||||
|
||||
export function parseConsulDurationMs(value: string): number | null {
|
||||
const input = value.trim();
|
||||
if (!input) return null;
|
||||
let total = 0;
|
||||
let offset = 0;
|
||||
for (const match of input.matchAll(DURATION_PART)) {
|
||||
if (match.index !== offset) return null;
|
||||
const amount = Number(match[1]);
|
||||
const multiplier = match[2] === "h" ? 3_600_000 : match[2] === "m" ? 60_000 : match[2] === "s" ? 1_000 : 1;
|
||||
total += amount * multiplier;
|
||||
offset += match[0].length;
|
||||
}
|
||||
return offset === input.length && Number.isFinite(total) && total >= 0 ? total : null;
|
||||
}
|
||||
|
||||
export function consulSessionRenewDelayMs(ttl: string): number {
|
||||
const ttlMs = parseConsulDurationMs(ttl) ?? 20_000;
|
||||
return Math.max(1_000, Math.min(30_000, Math.floor(ttlMs / 2)));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { KvInt64 } from "@/lib/backend/tauri";
|
||||
import type { ConsulWatchEvent } from "@/types/consul";
|
||||
|
||||
export interface ConsulWatchState {
|
||||
index: KvInt64 | null;
|
||||
connected: boolean;
|
||||
paused: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface ConsulWatchIdentity {
|
||||
connectionId: string;
|
||||
operationId: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export function isCurrentWatchEvent(identity: ConsulWatchIdentity, event: ConsulWatchEvent): boolean {
|
||||
return event.connectionId === identity.connectionId && event.operationId === identity.operationId && event.generation === identity.generation;
|
||||
}
|
||||
|
||||
export function nextWatchIndex(current: KvInt64 | null, returned: KvInt64 | null): { index: KvInt64; reset: boolean } {
|
||||
const currentValue = parseIndex(current) || 1n;
|
||||
const returnedValue = parseIndex(returned);
|
||||
if (returnedValue === 0n || returnedValue < currentValue) return { index: "1" as KvInt64, reset: true };
|
||||
return { index: returned ?? ("1" as KvInt64), reset: false };
|
||||
}
|
||||
|
||||
function parseIndex(index: KvInt64 | null): bigint {
|
||||
try {
|
||||
return BigInt(index ?? "0");
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function isColumnNameCopySeparator(value: unknown): value is ColumnNameCo
|
||||
}
|
||||
|
||||
// SQL 执行能力是产品能力,不代表数据库支持 SQL 标识符引用。
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "victoriametrics"]);
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
|
||||
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
|
||||
const UNQUOTABLE_DATABASE_TYPES = new Set<DatabaseType>(["jdbc", "iotdb"]);
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
|
||||
}
|
||||
|
||||
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
|
||||
return dbType !== "nacos" && dbType !== "hbase";
|
||||
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,6 +85,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
|
||||
jdbc: { deferred: "generic JDBC does not expose a reliable dialect-specific create target" },
|
||||
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
|
||||
nacos: { deferred: "Nacos namespace creation already uses the Nacos admin flow" },
|
||||
consul: { deferred: "Consul namespaces and partitions are connection scopes, not KV resources" },
|
||||
mqtt: { deferred: "MQTT topics are managed via the MQTT console" },
|
||||
} satisfies Record<DatabaseType, DatabaseNamespaceCreationMatrixEntry>;
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
|
||||
jdbc: { deferred: "generic JDBC does not expose reliable dialect-specific properties" },
|
||||
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
|
||||
nacos: { deferred: "Nacos namespace editing already uses the Nacos admin flow" },
|
||||
consul: { deferred: "Consul KV scopes are configured on the connection" },
|
||||
mqtt: { deferred: "MQTT topics are managed via the MQTT console" },
|
||||
} satisfies Record<DatabaseType, DatabasePropertyEditingEntry>;
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ export function classifyKvMutationError(error: unknown, creating: boolean, messa
|
||||
const sanitized = sanitizeKvMutationError(error);
|
||||
const normalized = sanitized.toLowerCase();
|
||||
|
||||
if (normalized.includes("etcd_key_already_exists") || (creating && normalized.includes("etcd_cas_conflict"))) {
|
||||
if (normalized.includes("etcd_key_already_exists") || normalized.includes("consul_key_already_exists") || (creating && (normalized.includes("etcd_cas_conflict") || normalized.includes("consul_cas_conflict")))) {
|
||||
return {
|
||||
kind: "keyAlreadyExists",
|
||||
message: messages.keyAlreadyExists || "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
};
|
||||
}
|
||||
if (normalized.includes("etcd_cas_conflict")) {
|
||||
if (normalized.includes("etcd_cas_conflict") || normalized.includes("consul_cas_conflict")) {
|
||||
return {
|
||||
kind: "conflict",
|
||||
message: messages.conflict || "The Key changed after it was loaded. Refresh it and try again.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type KvRootKind = "etcd" | "zookeeper";
|
||||
export type KvRootKind = "etcd" | "zookeeper" | "consul";
|
||||
|
||||
export function kvRootNodeLabel(kind: KvRootKind): string {
|
||||
return kind === "zookeeper" ? "/" : "Keys";
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { KvKeyMetadata, KvKeySummary } from "@/lib/backend/api";
|
||||
|
||||
export interface LazyKvKeyTreeNode extends KvKeyMetadata {
|
||||
kind: "lazy";
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
pathSegments: string[];
|
||||
/**
|
||||
* `null` means that a separator-based Consul listing returned a path ending
|
||||
* in `/`, so an exact GET is still required to distinguish a real Key from a
|
||||
* virtual prefix. ZooKeeper nodes and non-directory Consul Keys are known
|
||||
* values immediately.
|
||||
*/
|
||||
hasValue: boolean | null;
|
||||
hasChildren: boolean;
|
||||
children: LazyKvKeyTreeNode[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
continuation: string | null;
|
||||
}
|
||||
|
||||
export interface LazyKvKeyTreeState {
|
||||
pathStyle: LazyKvPathStyle;
|
||||
rootPath: string;
|
||||
roots: LazyKvKeyTreeNode[];
|
||||
rootContinuation: string | null;
|
||||
nodeByKey: Map<string, LazyKvKeyTreeNode>;
|
||||
}
|
||||
|
||||
export type LazyKvPathStyle = "absolute" | "relative";
|
||||
|
||||
export type LazyKvKeySummary = KvKeySummary & { hasValue?: boolean | null };
|
||||
|
||||
export function lazyKvRootPath(pathStyle: LazyKvPathStyle): string {
|
||||
return pathStyle === "absolute" ? "/" : "";
|
||||
}
|
||||
|
||||
export function normalizeLazyKvPath(path: string, pathStyle: LazyKvPathStyle): string {
|
||||
if (pathStyle === "absolute") return normalizeZooKeeperPath(path);
|
||||
return path.trim().replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
export function parentLazyKvPath(path: string, pathStyle: LazyKvPathStyle): string {
|
||||
if (pathStyle === "absolute") return parentZooKeeperPath(path);
|
||||
const normalized = normalizeLazyKvPath(path, pathStyle);
|
||||
const withoutTrailingSlash = normalized.replace(/\/+$/, "");
|
||||
const separator = withoutTrailingSlash.lastIndexOf("/");
|
||||
return separator < 0 ? "" : `${withoutTrailingSlash.slice(0, separator)}/`;
|
||||
}
|
||||
|
||||
export function createLazyKvChildPathDraft(parentPath: string, pathStyle: LazyKvPathStyle): string {
|
||||
if (pathStyle === "absolute") return createZooKeeperChildPathDraft(parentPath);
|
||||
const normalized = normalizeLazyKvPath(parentPath, pathStyle);
|
||||
return normalized ? (normalized.endsWith("/") ? normalized : `${normalized}/`) : "";
|
||||
}
|
||||
|
||||
export type LazyKvKeyTreeRow =
|
||||
| {
|
||||
type: "node";
|
||||
node: LazyKvKeyTreeNode;
|
||||
depth: number;
|
||||
}
|
||||
| {
|
||||
type: "loadMore";
|
||||
id: string;
|
||||
parentKey: string | null;
|
||||
depth: number;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export function normalizeZooKeeperPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === "/") return "/";
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
return withSlash.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
export function parentZooKeeperPath(path: string): string {
|
||||
const normalized = normalizeZooKeeperPath(path);
|
||||
if (normalized === "/") return "/";
|
||||
const parent = normalized.slice(0, normalized.lastIndexOf("/"));
|
||||
return parent || "/";
|
||||
}
|
||||
|
||||
export function createZooKeeperChildPathDraft(parentPath: string): string {
|
||||
const normalized = normalizeZooKeeperPath(parentPath);
|
||||
if (normalized === "/") return "";
|
||||
return `${normalized}/`;
|
||||
}
|
||||
|
||||
export function createLazyKvKeyTreeState(rootPath = "/", pathStyle: LazyKvPathStyle = "absolute"): LazyKvKeyTreeState {
|
||||
return {
|
||||
pathStyle,
|
||||
rootPath: normalizeLazyKvPath(rootPath, pathStyle),
|
||||
roots: [],
|
||||
rootContinuation: null,
|
||||
nodeByKey: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetLazyKvKeyTree(state: LazyKvKeyTreeState, rootPath = lazyKvRootPath(state.pathStyle)) {
|
||||
state.rootPath = normalizeLazyKvPath(rootPath, state.pathStyle);
|
||||
state.roots = [];
|
||||
state.rootContinuation = null;
|
||||
state.nodeByKey.clear();
|
||||
}
|
||||
|
||||
export function replaceLazyKvChildren(state: LazyKvKeyTreeState, parentKey: string | null, keys: LazyKvKeySummary[], continuation: string | null | undefined, options: { append?: boolean; filteredByAcls?: boolean | null } = {}) {
|
||||
const previous = new Map(state.nodeByKey);
|
||||
const nextChildren = keys.map((key) => lazyNodeFromSummary(key, state.pathStyle, previous.get(key.key)));
|
||||
const children = options.append ? mergeLazyChildren(parentKey ? state.nodeByKey.get(parentKey)?.children || [] : state.roots, nextChildren) : nextChildren;
|
||||
|
||||
if (parentKey) {
|
||||
const parent = state.nodeByKey.get(parentKey);
|
||||
if (!parent) return;
|
||||
parent.children = children;
|
||||
parent.loaded = true;
|
||||
parent.continuation = continuation || null;
|
||||
// A separator listing cannot initially distinguish a real `key/` from a
|
||||
// virtual directory. Once its complete, unfiltered child listing is empty,
|
||||
// it is known to be a leaf and must no longer render an expander.
|
||||
if (children.length > 0 || continuation) parent.hasChildren = true;
|
||||
else if (!options.filteredByAcls) parent.hasChildren = false;
|
||||
} else {
|
||||
state.roots = children;
|
||||
state.rootContinuation = continuation || null;
|
||||
}
|
||||
|
||||
rebuildLazyNodeIndex(state);
|
||||
}
|
||||
|
||||
export function replaceLazyKvFocusedRoot(state: LazyKvKeyTreeState, root: LazyKvKeySummary, keys: LazyKvKeySummary[], continuation: string | null | undefined) {
|
||||
const previous = new Map(state.nodeByKey);
|
||||
const focusedPath = normalizeLazyKvPath(root.key, state.pathStyle);
|
||||
const childNodes = keys.map((key) => lazyNodeFromSummary(key, state.pathStyle, previous.get(key.key)));
|
||||
const chain = focusedPathChain(focusedPath, state.pathStyle);
|
||||
let rootNode: LazyKvKeyTreeNode | null = null;
|
||||
let parentNode: LazyKvKeyTreeNode | null = null;
|
||||
|
||||
for (const path of chain) {
|
||||
const isFocusedPath = path === focusedPath;
|
||||
const node = lazyNodeFromSummary(isFocusedPath ? { ...root, key: focusedPath } : { key: path, numChildren: 1, hasValue: false }, state.pathStyle, previous.get(path));
|
||||
// Ancestors are directories, but an exact focused Key with no children must
|
||||
// remain a leaf. Otherwise searching for that Key renders it as a folder.
|
||||
node.hasChildren = !isFocusedPath || (node.numChildren ?? 0) > 0;
|
||||
node.loaded = true;
|
||||
node.continuation = null;
|
||||
node.children = [];
|
||||
|
||||
if (parentNode) parentNode.children = [node];
|
||||
else rootNode = node;
|
||||
parentNode = node;
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.children = childNodes;
|
||||
parentNode.hasChildren = parentNode.hasChildren || childNodes.length > 0 || !!continuation;
|
||||
parentNode.continuation = continuation || null;
|
||||
}
|
||||
|
||||
state.roots = rootNode ? [rootNode] : [];
|
||||
state.rootContinuation = null;
|
||||
rebuildLazyNodeIndex(state);
|
||||
}
|
||||
|
||||
export function flattenLazyKvKeyTree(state: LazyKvKeyTreeState, expandedIds: ReadonlySet<string>): LazyKvKeyTreeRow[] {
|
||||
const rows = flattenLazyNodes(state.roots, expandedIds, 0);
|
||||
if (state.rootContinuation) {
|
||||
rows.push({ type: "loadMore", id: "lazy-load-more:root", parentKey: null, depth: 0, loading: false });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function lazyExpandedKeyFromId(id: string): string | null {
|
||||
return id.startsWith("lazy:") ? id.slice("lazy:".length) : null;
|
||||
}
|
||||
|
||||
function flattenLazyNodes(nodes: LazyKvKeyTreeNode[], expandedIds: ReadonlySet<string>, depth: number): LazyKvKeyTreeRow[] {
|
||||
const rows: LazyKvKeyTreeRow[] = [];
|
||||
for (const node of nodes) {
|
||||
rows.push({ type: "node", node, depth });
|
||||
if (node.hasChildren && expandedIds.has(node.id)) {
|
||||
rows.push(...flattenLazyNodes(node.children, expandedIds, depth + 1));
|
||||
if (node.continuation) {
|
||||
rows.push({ type: "loadMore", id: `lazy-load-more:${node.key}`, parentKey: node.key, depth: depth + 1, loading: node.loading });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function lazyNodeFromSummary(summary: LazyKvKeySummary, pathStyle: LazyKvPathStyle, previous?: LazyKvKeyTreeNode): LazyKvKeyTreeNode {
|
||||
const pathSegments = keySegments(summary.key);
|
||||
const hasValue = summary.hasValue !== undefined ? summary.hasValue : pathStyle === "absolute" || !summary.key.endsWith("/") ? true : (previous?.hasValue ?? null);
|
||||
return {
|
||||
...summary,
|
||||
kind: "lazy",
|
||||
id: `lazy:${summary.key}`,
|
||||
label: pathSegments[pathSegments.length - 1] || summary.key || "/",
|
||||
key: summary.key,
|
||||
pathSegments,
|
||||
hasValue,
|
||||
hasChildren: (summary.numChildren ?? 0) > 0 || summary.key.endsWith("/"),
|
||||
children: previous?.children || [],
|
||||
loaded: previous?.loaded || false,
|
||||
loading: previous?.loading || false,
|
||||
continuation: previous?.continuation || null,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLazyChildren(existing: LazyKvKeyTreeNode[], incoming: LazyKvKeyTreeNode[]): LazyKvKeyTreeNode[] {
|
||||
const seen = new Set(existing.map((node) => node.key));
|
||||
return [...existing, ...incoming.filter((node) => !seen.has(node.key))];
|
||||
}
|
||||
|
||||
function rebuildLazyNodeIndex(state: LazyKvKeyTreeState) {
|
||||
state.nodeByKey.clear();
|
||||
const visit = (nodes: LazyKvKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
state.nodeByKey.set(node.key, node);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(state.roots);
|
||||
}
|
||||
|
||||
function keySegments(key: string): string[] {
|
||||
return key.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function focusedPathChain(path: string, pathStyle: LazyKvPathStyle): string[] {
|
||||
const segments = keySegments(path);
|
||||
const trailingSlash = pathStyle === "relative" && path.endsWith("/");
|
||||
const chain: string[] = [];
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const joined = segments.slice(0, index + 1).join("/");
|
||||
const isLeaf = index === segments.length - 1;
|
||||
chain.push(pathStyle === "absolute" ? `/${joined}` : isLeaf && !trailingSlash ? joined : `${joined}/`);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
@@ -48,6 +48,14 @@ export type ActiveTabSidebarTarget =
|
||||
type: "zookeeper-root";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "consul-root";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "consul-overview";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "mq-tenant";
|
||||
connectionId: string;
|
||||
@@ -147,6 +155,12 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
|
||||
if (tab.mode === "zookeeper") {
|
||||
return { type: "zookeeper-root", connectionId: tab.connectionId };
|
||||
}
|
||||
if (tab.mode === "consul") {
|
||||
return { type: "consul-root", connectionId: tab.connectionId };
|
||||
}
|
||||
if (tab.mode === "consul-overview") {
|
||||
return { type: "consul-overview", connectionId: tab.connectionId };
|
||||
}
|
||||
|
||||
if (tab.mode === "mq" && tab.mqTenant) {
|
||||
return { type: "mq-tenant", connectionId: tab.connectionId, tenant: tab.mqTenant };
|
||||
@@ -226,6 +240,12 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
|
||||
if (target.type === "zookeeper-root") {
|
||||
return node.type === "zookeeper-root" && node.connectionId === target.connectionId;
|
||||
}
|
||||
if (target.type === "consul-root") {
|
||||
return node.type === "consul-root" && node.connectionId === target.connectionId;
|
||||
}
|
||||
if (target.type === "consul-overview") {
|
||||
return node.type === "consul-overview" && node.connectionId === target.connectionId;
|
||||
}
|
||||
|
||||
if (target.type === "mq-tenant") {
|
||||
return node.type === "mq-tenant" && node.connectionId === target.connectionId && (node.mqTenant || node.label) === target.tenant;
|
||||
|
||||
@@ -19,6 +19,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
|
||||
"etcd-dashboard",
|
||||
"etcd-access-control",
|
||||
"zookeeper-root",
|
||||
"consul-root",
|
||||
"consul-overview",
|
||||
"mongo-gridfs",
|
||||
"mongo-bucket",
|
||||
"vector-collection",
|
||||
|
||||
@@ -8,7 +8,35 @@ 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", "mqtt-topic", "etcd-root", "etcd-dashboard", "etcd-access-control", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
|
||||
const toggleLeafNodeTypes = new Set<TreeNodeType>([
|
||||
"redis-db",
|
||||
"mq-tenant",
|
||||
"mqtt-topic",
|
||||
"etcd-root",
|
||||
"etcd-dashboard",
|
||||
"etcd-access-control",
|
||||
"zookeeper-root",
|
||||
"consul-root",
|
||||
"consul-overview",
|
||||
"mongo-gridfs",
|
||||
"mongo-collection",
|
||||
"mongo-bucket",
|
||||
"vector-collection",
|
||||
"elasticsearch-index",
|
||||
"user-admin",
|
||||
]);
|
||||
// These are application entry points rather than database objects. They should
|
||||
// always navigate on a single click, even when the user prefers double-click
|
||||
// activation for ordinary tree objects.
|
||||
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview"]);
|
||||
|
||||
export function isDirectNavigationTreeNode(type: TreeNodeType): boolean {
|
||||
return directNavigationTreeNodeTypes.has(type);
|
||||
}
|
||||
|
||||
export function shouldActivateTreeNodeOnSingleClick(type: TreeNodeType, activation: SidebarActivation = "single"): boolean {
|
||||
return activation !== "double" || isDirectNavigationTreeNode(type);
|
||||
}
|
||||
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"]);
|
||||
@@ -35,7 +63,7 @@ export function isDocumentBrowserTreeNode(type: TreeNodeType): boolean {
|
||||
}
|
||||
|
||||
export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single"): TreeNodeRowAction {
|
||||
if (activation === "double") return "none";
|
||||
if (!shouldActivateTreeNodeOnSingleClick(type, activation)) return "none";
|
||||
if (type === "extension") return "open-extension-details";
|
||||
if (dataNodeTypes.has(type)) return "open-data";
|
||||
if (sourceNodeTypes.has(type)) return "open-source";
|
||||
|
||||
@@ -36,6 +36,7 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
|
||||
case "etcd-access-control":
|
||||
return { icon: ShieldCheck, colorClass: "text-sky-500" };
|
||||
case "zookeeper-root":
|
||||
case "consul-root":
|
||||
return { icon: Database, colorClass: "text-blue-500" };
|
||||
case "table":
|
||||
return { icon: Table, colorClass: "text-green-500" };
|
||||
|
||||
@@ -22,7 +22,7 @@ export function elasticsearchRestRequestRanges(sql: string, databaseType?: Datab
|
||||
return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
|
||||
}
|
||||
|
||||
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "neo4j", "victoriametrics"]);
|
||||
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
|
||||
|
||||
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
|
||||
return !!databaseType && (databaseType === "redis" || isElasticsearchCompatibleDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
|
||||
|
||||
@@ -119,6 +119,14 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
|
||||
if (compact) return connectionDisplayName(tab.connectionId);
|
||||
return `${connectionDisplayName(tab.connectionId)}@keys`;
|
||||
}
|
||||
if (tab.mode === "consul") {
|
||||
if (compact) return connectionDisplayName(tab.connectionId);
|
||||
return `${connectionDisplayName(tab.connectionId)}@keys`;
|
||||
}
|
||||
if (tab.mode === "consul-overview") {
|
||||
if (compact) return connectionDisplayName(tab.connectionId);
|
||||
return `${connectionDisplayName(tab.connectionId)}@${t("consul.ui.overview")}`;
|
||||
}
|
||||
if (tab.mode === "objects") {
|
||||
const schema = tab.objectBrowser?.schema;
|
||||
if (compact) return schema || tab.title;
|
||||
@@ -418,6 +426,8 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
|
||||
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 === "consul") return t("tabs.consul");
|
||||
if (tab.mode === "consul-overview") return t("consul.ui.overview");
|
||||
if (tab.mode === "nacos") return "Nacos";
|
||||
if (tab.mode === "objects") return t("tabs.objects");
|
||||
if (tab.mode === "users") return t("tabs.users");
|
||||
|
||||
@@ -1,193 +1 @@
|
||||
import type { KvKeyMetadata, KvKeySummary } from "@/lib/backend/api";
|
||||
|
||||
export interface LazyKvKeyTreeNode extends KvKeyMetadata {
|
||||
kind: "lazy";
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
pathSegments: string[];
|
||||
hasChildren: boolean;
|
||||
children: LazyKvKeyTreeNode[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
continuation: string | null;
|
||||
}
|
||||
|
||||
export interface LazyKvKeyTreeState {
|
||||
rootPath: string;
|
||||
roots: LazyKvKeyTreeNode[];
|
||||
rootContinuation: string | null;
|
||||
nodeByKey: Map<string, LazyKvKeyTreeNode>;
|
||||
}
|
||||
|
||||
export type LazyKvKeyTreeRow =
|
||||
| {
|
||||
type: "node";
|
||||
node: LazyKvKeyTreeNode;
|
||||
depth: number;
|
||||
}
|
||||
| {
|
||||
type: "loadMore";
|
||||
id: string;
|
||||
parentKey: string | null;
|
||||
depth: number;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export function normalizeZooKeeperPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === "/") return "/";
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
return withSlash.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
export function parentZooKeeperPath(path: string): string {
|
||||
const normalized = normalizeZooKeeperPath(path);
|
||||
if (normalized === "/") return "/";
|
||||
const parent = normalized.slice(0, normalized.lastIndexOf("/"));
|
||||
return parent || "/";
|
||||
}
|
||||
|
||||
export function createZooKeeperChildPathDraft(parentPath: string): string {
|
||||
const normalized = normalizeZooKeeperPath(parentPath);
|
||||
if (normalized === "/") return "";
|
||||
return `${normalized}/`;
|
||||
}
|
||||
|
||||
export function createLazyKvKeyTreeState(rootPath = "/"): LazyKvKeyTreeState {
|
||||
return {
|
||||
rootPath: normalizeZooKeeperPath(rootPath),
|
||||
roots: [],
|
||||
rootContinuation: null,
|
||||
nodeByKey: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetLazyKvKeyTree(state: LazyKvKeyTreeState, rootPath = "/") {
|
||||
state.rootPath = normalizeZooKeeperPath(rootPath);
|
||||
state.roots = [];
|
||||
state.rootContinuation = null;
|
||||
state.nodeByKey.clear();
|
||||
}
|
||||
|
||||
export function replaceLazyKvChildren(state: LazyKvKeyTreeState, parentKey: string | null, keys: KvKeySummary[], continuation: string | null | undefined, options: { append?: boolean } = {}) {
|
||||
const previous = new Map(state.nodeByKey);
|
||||
const nextChildren = keys.map((key) => lazyNodeFromSummary(key, previous.get(key.key)));
|
||||
const children = options.append ? mergeLazyChildren(parentKey ? state.nodeByKey.get(parentKey)?.children || [] : state.roots, nextChildren) : nextChildren;
|
||||
|
||||
if (parentKey) {
|
||||
const parent = state.nodeByKey.get(parentKey);
|
||||
if (!parent) return;
|
||||
parent.children = children;
|
||||
parent.loaded = true;
|
||||
parent.continuation = continuation || null;
|
||||
} else {
|
||||
state.roots = children;
|
||||
state.rootContinuation = continuation || null;
|
||||
}
|
||||
|
||||
rebuildLazyNodeIndex(state);
|
||||
}
|
||||
|
||||
export function replaceLazyKvFocusedRoot(state: LazyKvKeyTreeState, root: KvKeySummary, keys: KvKeySummary[], continuation: string | null | undefined) {
|
||||
const previous = new Map(state.nodeByKey);
|
||||
const focusedPath = normalizeZooKeeperPath(root.key);
|
||||
const childNodes = keys.map((key) => lazyNodeFromSummary(key, previous.get(key.key)));
|
||||
const chain = focusedPathChain(focusedPath);
|
||||
let rootNode: LazyKvKeyTreeNode | null = null;
|
||||
let parentNode: LazyKvKeyTreeNode | null = null;
|
||||
|
||||
for (const path of chain) {
|
||||
const node = lazyNodeFromSummary(path === focusedPath ? { ...root, key: focusedPath } : { key: path, numChildren: 1 }, previous.get(path));
|
||||
node.hasChildren = true;
|
||||
node.loaded = true;
|
||||
node.continuation = null;
|
||||
node.children = [];
|
||||
|
||||
if (parentNode) parentNode.children = [node];
|
||||
else rootNode = node;
|
||||
parentNode = node;
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.children = childNodes;
|
||||
parentNode.hasChildren = parentNode.hasChildren || childNodes.length > 0 || !!continuation;
|
||||
parentNode.continuation = continuation || null;
|
||||
}
|
||||
|
||||
state.roots = rootNode ? [rootNode] : [];
|
||||
state.rootContinuation = null;
|
||||
rebuildLazyNodeIndex(state);
|
||||
}
|
||||
|
||||
export function flattenLazyKvKeyTree(state: LazyKvKeyTreeState, expandedIds: ReadonlySet<string>): LazyKvKeyTreeRow[] {
|
||||
const rows = flattenLazyNodes(state.roots, expandedIds, 0);
|
||||
if (state.rootContinuation) {
|
||||
rows.push({ type: "loadMore", id: "lazy-load-more:root", parentKey: null, depth: 0, loading: false });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function lazyExpandedKeyFromId(id: string): string | null {
|
||||
return id.startsWith("lazy:") ? id.slice("lazy:".length) : null;
|
||||
}
|
||||
|
||||
function flattenLazyNodes(nodes: LazyKvKeyTreeNode[], expandedIds: ReadonlySet<string>, depth: number): LazyKvKeyTreeRow[] {
|
||||
const rows: LazyKvKeyTreeRow[] = [];
|
||||
for (const node of nodes) {
|
||||
rows.push({ type: "node", node, depth });
|
||||
if (node.hasChildren && expandedIds.has(node.id)) {
|
||||
rows.push(...flattenLazyNodes(node.children, expandedIds, depth + 1));
|
||||
if (node.continuation) {
|
||||
rows.push({ type: "loadMore", id: `lazy-load-more:${node.key}`, parentKey: node.key, depth: depth + 1, loading: node.loading });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function lazyNodeFromSummary(summary: KvKeySummary, previous?: LazyKvKeyTreeNode): LazyKvKeyTreeNode {
|
||||
const pathSegments = keySegments(summary.key);
|
||||
return {
|
||||
...summary,
|
||||
kind: "lazy",
|
||||
id: `lazy:${summary.key}`,
|
||||
label: pathSegments[pathSegments.length - 1] || summary.key || "/",
|
||||
key: summary.key,
|
||||
pathSegments,
|
||||
hasChildren: (summary.numChildren ?? 0) > 0,
|
||||
children: previous?.children || [],
|
||||
loaded: previous?.loaded || false,
|
||||
loading: previous?.loading || false,
|
||||
continuation: previous?.continuation || null,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLazyChildren(existing: LazyKvKeyTreeNode[], incoming: LazyKvKeyTreeNode[]): LazyKvKeyTreeNode[] {
|
||||
const seen = new Set(existing.map((node) => node.key));
|
||||
return [...existing, ...incoming.filter((node) => !seen.has(node.key))];
|
||||
}
|
||||
|
||||
function rebuildLazyNodeIndex(state: LazyKvKeyTreeState) {
|
||||
state.nodeByKey.clear();
|
||||
const visit = (nodes: LazyKvKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
state.nodeByKey.set(node.key, node);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(state.roots);
|
||||
}
|
||||
|
||||
function keySegments(key: string): string[] {
|
||||
return key.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function focusedPathChain(path: string): string[] {
|
||||
const segments = keySegments(path);
|
||||
const chain: string[] = [];
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
chain.push(`/${segments.slice(0, index + 1).join("/")}`);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
export * from "@/lib/kv/slashPrefixLazyKeyTree";
|
||||
@@ -985,6 +985,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "Apache ZooKeeper",
|
||||
consul: "Consul",
|
||||
duckdb: "DuckDB",
|
||||
clickhouse: "ClickHouse",
|
||||
sqlserver: "SQL Server",
|
||||
@@ -2744,6 +2745,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadEtcdRoot(connectionId);
|
||||
} else if (config.db_type === "zookeeper") {
|
||||
await loadZooKeeperRoot(connectionId);
|
||||
} else if (config.db_type === "consul") {
|
||||
await loadConsulRoot(connectionId);
|
||||
} else if (config.db_type === "mongodb") {
|
||||
await loadMongoDatabases(connectionId);
|
||||
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch") {
|
||||
@@ -3291,7 +3294,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
|
||||
if (!connectedIds.value.has(connectionId)) return;
|
||||
const config = getConfig(connectionId);
|
||||
if (!config || ["redis", "etcd", "zookeeper", "mongodb", "elasticsearch", "easysearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
|
||||
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "elasticsearch", "easysearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node || node.type !== "connection" || node.isLoading || hasConnectionMetadataChildren(node.children)) return;
|
||||
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
|
||||
@@ -3450,6 +3453,52 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConsulRoot(connectionId: string) {
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node) return;
|
||||
|
||||
let load = beginTreeNodeLoad(node);
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
load = reclaimTreeNodeLoad(load, node);
|
||||
const targetNode = treeNodeLoadTarget(load);
|
||||
if (!targetNode) return;
|
||||
setChildren(
|
||||
targetNode,
|
||||
withSavedSqlRoot(
|
||||
connectionId,
|
||||
[
|
||||
{
|
||||
id: `${connectionId}:consul`,
|
||||
label: kvRootNodeLabel("consul"),
|
||||
type: "consul-root" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: `${connectionId}:consul-overview`,
|
||||
label: i18n.global.t("consul.ui.overview"),
|
||||
type: "consul-overview" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
targetNode,
|
||||
),
|
||||
);
|
||||
targetNode.isExpanded = true;
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e, load);
|
||||
throw e;
|
||||
} finally {
|
||||
finishTreeNodeLoad(load);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMqttTopics(connectionId: string) {
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node) return;
|
||||
@@ -5238,6 +5287,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadEtcdRoot(node.connectionId);
|
||||
} else if (config?.db_type === "zookeeper") {
|
||||
await loadZooKeeperRoot(node.connectionId);
|
||||
} else if (config?.db_type === "consul") {
|
||||
await loadConsulRoot(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
@@ -7152,6 +7203,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
refreshRedisDbKeyCounts,
|
||||
loadEtcdRoot,
|
||||
loadZooKeeperRoot,
|
||||
loadConsulRoot,
|
||||
loadMqTenants,
|
||||
loadMqttTopics,
|
||||
loadNacosNamespaces,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulScope } from "@/types/consul";
|
||||
|
||||
function emptyScope(): ConsulScope {
|
||||
return { datacenter: "", namespace: "", partition: "" };
|
||||
}
|
||||
|
||||
export const useConsulStore = defineStore("consul", () => {
|
||||
const activeConnectionId = ref("");
|
||||
const scope = reactive<ConsulScope>(emptyScope());
|
||||
const generation = ref(0);
|
||||
const activeOperations = reactive(new Map<string, { connectionId: string; scope: ConsulScope; generation: number }>());
|
||||
|
||||
const scopeKey = computed(() => `${scope.datacenter}\0${scope.partition}\0${scope.namespace}`);
|
||||
|
||||
function bindConnection(connectionId: string, nextScope: ConsulScope = emptyScope()) {
|
||||
if (activeConnectionId.value === connectionId && scopeKey.value === scopeIdentity(nextScope)) return;
|
||||
invalidate();
|
||||
activeConnectionId.value = connectionId;
|
||||
Object.assign(scope, nextScope);
|
||||
}
|
||||
|
||||
function switchScope(nextScope: ConsulScope) {
|
||||
if (scopeKey.value === scopeIdentity(nextScope)) return;
|
||||
invalidate();
|
||||
Object.assign(scope, nextScope);
|
||||
}
|
||||
|
||||
function registerOperation(operationId: string) {
|
||||
if (activeOperations.has(operationId)) throw new Error("CONSUL_OPERATION_ALREADY_RUNNING");
|
||||
activeOperations.set(operationId, {
|
||||
connectionId: activeConnectionId.value,
|
||||
scope: { ...scope },
|
||||
generation: generation.value,
|
||||
});
|
||||
}
|
||||
|
||||
function completeOperation(operationId: string) {
|
||||
activeOperations.delete(operationId);
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
for (const [operationId, operation] of activeOperations) {
|
||||
void api.consulCancelBlocking(operation.connectionId, operation.scope, operation.generation, operationId).catch(() => undefined);
|
||||
}
|
||||
activeOperations.clear();
|
||||
generation.value += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
activeConnectionId,
|
||||
scope,
|
||||
scopeKey,
|
||||
generation,
|
||||
bindConnection,
|
||||
switchScope,
|
||||
registerOperation,
|
||||
completeOperation,
|
||||
invalidate,
|
||||
};
|
||||
});
|
||||
|
||||
function scopeIdentity(scope: ConsulScope): string {
|
||||
return `${scope.datacenter}\0${scope.partition}\0${scope.namespace}`;
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
import type { KvInt64, KvValue } from "@/lib/backend/tauri";
|
||||
|
||||
export type ConsulCapabilityStatus = "supported" | "unsupported" | "disabled" | "forbidden" | "unknown";
|
||||
|
||||
export interface ConsulScope {
|
||||
datacenter: string;
|
||||
namespace: string;
|
||||
partition: string;
|
||||
}
|
||||
|
||||
export interface ConsulResponseMetadata {
|
||||
index: KvInt64 | null;
|
||||
filteredByAcls: boolean | null;
|
||||
knownLeader: boolean | null;
|
||||
lastContact: KvInt64 | null;
|
||||
queryBackend: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulCapabilities {
|
||||
version: string | null;
|
||||
datacenter: string | null;
|
||||
nodeName: string | null;
|
||||
server: boolean | null;
|
||||
edition: string | null;
|
||||
agent: ConsulCapabilityStatus;
|
||||
catalog: ConsulCapabilityStatus;
|
||||
health: ConsulCapabilityStatus;
|
||||
sessions: ConsulCapabilityStatus;
|
||||
acl: ConsulCapabilityStatus;
|
||||
authMethods: ConsulCapabilityStatus;
|
||||
bindingRules: ConsulCapabilityStatus;
|
||||
templatedPolicies: ConsulCapabilityStatus;
|
||||
namespaces: ConsulCapabilityStatus;
|
||||
partitions: ConsulCapabilityStatus;
|
||||
configEntries: ConsulCapabilityStatus;
|
||||
intentions: ConsulCapabilityStatus;
|
||||
peering: ConsulCapabilityStatus;
|
||||
exportedServices: ConsulCapabilityStatus;
|
||||
preparedQueries: ConsulCapabilityStatus;
|
||||
events: ConsulCapabilityStatus;
|
||||
coordinates: ConsulCapabilityStatus;
|
||||
operatorAutopilot: ConsulCapabilityStatus;
|
||||
operatorRaft: ConsulCapabilityStatus;
|
||||
operatorKeyring: ConsulCapabilityStatus;
|
||||
operatorUsage: ConsulCapabilityStatus;
|
||||
operatorLicense: ConsulCapabilityStatus;
|
||||
audit: ConsulCapabilityStatus;
|
||||
}
|
||||
|
||||
export interface ConsulKvRecord {
|
||||
key: string;
|
||||
value: KvValue;
|
||||
flags: KvInt64;
|
||||
createIndex: KvInt64;
|
||||
modifyIndex: KvInt64;
|
||||
lockIndex: KvInt64;
|
||||
session: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulRecursiveListResponse {
|
||||
entries: ConsulKvRecord[];
|
||||
index: KvInt64 | null;
|
||||
filteredByAcls: boolean | null;
|
||||
totalValueBytes: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulSearchRequest {
|
||||
requestId: string;
|
||||
prefix: string;
|
||||
query: string;
|
||||
searchKeys: boolean;
|
||||
searchValues: boolean;
|
||||
caseSensitive: boolean;
|
||||
limit: number;
|
||||
maxScan: number;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface ConsulSearchMatch extends ConsulKvRecord {
|
||||
matchesKey: boolean;
|
||||
matchesValue: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulSearchResponse {
|
||||
matches: ConsulSearchMatch[];
|
||||
scanned: number;
|
||||
matched: number;
|
||||
limited: boolean;
|
||||
filteredByAcls: boolean | null;
|
||||
}
|
||||
|
||||
export interface ConsulSearchProgress {
|
||||
running: boolean;
|
||||
scanned: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export type ConsulExportScopeKind = "key" | "prefix";
|
||||
|
||||
export interface ConsulExportRequest {
|
||||
path: string;
|
||||
kind: ConsulExportScopeKind;
|
||||
}
|
||||
|
||||
export interface ConsulBundleScope {
|
||||
datacenter: string;
|
||||
namespace: string;
|
||||
partition: string;
|
||||
}
|
||||
|
||||
export interface ConsulBundleEntry {
|
||||
key: string;
|
||||
value: KvValue;
|
||||
flags: KvInt64;
|
||||
createIndex: KvInt64 | null;
|
||||
modifyIndex: KvInt64 | null;
|
||||
}
|
||||
|
||||
export interface ConsulKvBundle {
|
||||
format: "dbx-consul-kv-bundle";
|
||||
version: 1;
|
||||
exportedAtUnixMs: number;
|
||||
prefix: string;
|
||||
scopeKind: ConsulExportScopeKind;
|
||||
source: ConsulBundleScope;
|
||||
entries: ConsulBundleEntry[];
|
||||
}
|
||||
|
||||
export type ConsulImportConflictPolicy = "abort" | "skip" | "cas";
|
||||
export type ConsulImportOperation = "create" | "update" | "unchanged" | "skipped" | "conflict" | "locked";
|
||||
|
||||
export interface ConsulImportRequest {
|
||||
bundle: ConsulKvBundle;
|
||||
policy: ConsulImportConflictPolicy;
|
||||
previewId?: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulImportPreviewRow {
|
||||
key: string;
|
||||
operation: ConsulImportOperation;
|
||||
expectedModifyIndex: KvInt64 | null;
|
||||
targetSession: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulImportPreview {
|
||||
previewId: string;
|
||||
rows: ConsulImportPreviewRow[];
|
||||
canApply: boolean;
|
||||
creates: number;
|
||||
updates: number;
|
||||
unchanged: number;
|
||||
skipped: number;
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
export type ConsulImportOutcome = "succeeded" | "conflicted" | "skipped" | "failed";
|
||||
|
||||
export interface ConsulImportResultItem {
|
||||
key: string;
|
||||
outcome: ConsulImportOutcome;
|
||||
message: string | null;
|
||||
batch: number | null;
|
||||
opIndex: number | null;
|
||||
}
|
||||
|
||||
export interface ConsulImportReport {
|
||||
items: ConsulImportResultItem[];
|
||||
succeeded: number;
|
||||
conflicted: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
atomic: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulDeleteCandidate {
|
||||
key: string;
|
||||
modifyIndex: KvInt64;
|
||||
session: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulDeletePrefixPreview {
|
||||
prefix: string;
|
||||
candidates: ConsulDeleteCandidate[];
|
||||
filteredByAcls: boolean | null;
|
||||
complete: boolean;
|
||||
canExecute: boolean;
|
||||
locked: number;
|
||||
}
|
||||
|
||||
export interface ConsulDeletePrefixRequest {
|
||||
prefix: string;
|
||||
expected: ConsulDeleteCandidate[];
|
||||
}
|
||||
|
||||
export type ConsulDeleteOutcome = "succeeded" | "conflicted" | "failed";
|
||||
|
||||
export interface ConsulDeleteResultItem {
|
||||
key: string;
|
||||
outcome: ConsulDeleteOutcome;
|
||||
message: string | null;
|
||||
batch: number | null;
|
||||
opIndex: number | null;
|
||||
}
|
||||
|
||||
export type ConsulTxnVerb = "get" | "get-tree" | "set" | "cas" | "delete" | "delete-cas" | "delete-tree" | "lock" | "unlock" | "check-index" | "check-not-exists" | "check-session";
|
||||
|
||||
export interface ConsulTxnKvOperation {
|
||||
verb: ConsulTxnVerb;
|
||||
key: string;
|
||||
value: KvValue | null;
|
||||
flags: KvInt64 | null;
|
||||
index: KvInt64 | null;
|
||||
session: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulTxnRequest {
|
||||
operations: ConsulTxnKvOperation[];
|
||||
}
|
||||
export interface ConsulTxnError {
|
||||
opIndex: number;
|
||||
message: string;
|
||||
}
|
||||
export interface ConsulTxnKvResult {
|
||||
key: string;
|
||||
value: KvValue | null;
|
||||
flags: KvInt64;
|
||||
createIndex: KvInt64;
|
||||
modifyIndex: KvInt64;
|
||||
lockIndex: KvInt64;
|
||||
session: string | null;
|
||||
}
|
||||
export interface ConsulTxnResult {
|
||||
committed: boolean;
|
||||
errors: ConsulTxnError[];
|
||||
results: ConsulTxnKvResult[];
|
||||
}
|
||||
|
||||
export interface ConsulBlockingRequest {
|
||||
operationId: string;
|
||||
generation: number;
|
||||
key: string;
|
||||
prefix: boolean;
|
||||
index: KvInt64 | null;
|
||||
waitSeconds: number;
|
||||
}
|
||||
|
||||
export interface ConsulBlockingResponse {
|
||||
entries: ConsulKvRecord[];
|
||||
metadata: ConsulResponseMetadata;
|
||||
changed: boolean;
|
||||
timedOut: boolean;
|
||||
indexReset: boolean;
|
||||
}
|
||||
|
||||
export type ConsulDomainWatchTarget =
|
||||
| { kind: "catalogNodes" }
|
||||
| { kind: "catalogServices" }
|
||||
| { kind: "catalogServiceNodes"; service: string }
|
||||
| { kind: "catalogNodeServices"; node: string }
|
||||
| { kind: "healthNode"; node: string }
|
||||
| { kind: "healthServiceChecks"; service: string }
|
||||
| { kind: "healthServiceInstances"; service: string; passing: boolean | null }
|
||||
| { kind: "healthState"; state: string };
|
||||
|
||||
export interface ConsulDomainWatchRequest {
|
||||
operationId: string;
|
||||
generation: number;
|
||||
target: ConsulDomainWatchTarget;
|
||||
index: KvInt64 | null;
|
||||
waitSeconds: number;
|
||||
}
|
||||
|
||||
export type ConsulDomainWatchItems = ConsulCatalogNode[] | Record<string, string[]> | ConsulCatalogServiceNode[] | ConsulNodeServices | ConsulHealthCheck[] | ConsulServiceInstance[];
|
||||
|
||||
export interface ConsulDomainWatchResponse<T extends ConsulDomainWatchItems> {
|
||||
items: T;
|
||||
metadata: ConsulResponseMetadata;
|
||||
changed: boolean;
|
||||
timedOut: boolean;
|
||||
indexReset: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulWatchEvent {
|
||||
connectionId: string;
|
||||
operationId: string;
|
||||
generation: number;
|
||||
result: ConsulBlockingResponse | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulPreparedQueryService {
|
||||
Service: string;
|
||||
Near: string;
|
||||
OnlyPassing: boolean;
|
||||
Tags: string[];
|
||||
}
|
||||
export interface ConsulPreparedQuery {
|
||||
ID: string;
|
||||
Name: string;
|
||||
Session: string;
|
||||
Service: ConsulPreparedQueryService;
|
||||
CreateIndex: KvInt64;
|
||||
ModifyIndex: KvInt64;
|
||||
}
|
||||
export interface ConsulPreparedQueryInput {
|
||||
name: string;
|
||||
session: string;
|
||||
service: ConsulPreparedQueryService;
|
||||
}
|
||||
export interface ConsulPreparedQueryExecuteRequest {
|
||||
query: string;
|
||||
limit: number;
|
||||
connect: boolean;
|
||||
}
|
||||
export interface ConsulPreparedNodeIdentity {
|
||||
Node: string;
|
||||
Address: string;
|
||||
Datacenter: string;
|
||||
}
|
||||
export interface ConsulPreparedServiceIdentity {
|
||||
ID: string;
|
||||
Service: string;
|
||||
Address: string;
|
||||
Port: number;
|
||||
Tags: string[];
|
||||
}
|
||||
export interface ConsulPreparedQueryNode {
|
||||
Node: ConsulPreparedNodeIdentity;
|
||||
Service: ConsulPreparedServiceIdentity;
|
||||
}
|
||||
export interface ConsulPreparedQueryExecuteResponse {
|
||||
Service: string;
|
||||
Nodes: ConsulPreparedQueryNode[];
|
||||
DNS: { TTL: string };
|
||||
Datacenter: string;
|
||||
}
|
||||
export interface ConsulEventFireRequest {
|
||||
name: string;
|
||||
payloadBase64: string;
|
||||
nodeFilter: string;
|
||||
serviceFilter: string;
|
||||
tagFilter: string;
|
||||
}
|
||||
export interface ConsulEvent {
|
||||
ID: string;
|
||||
Name: string;
|
||||
Payload: string | null;
|
||||
NodeFilter: string;
|
||||
ServiceFilter: string;
|
||||
TagFilter: string;
|
||||
LTime: KvInt64;
|
||||
}
|
||||
export interface ConsulCoordinate {
|
||||
Node: string;
|
||||
Segment: string;
|
||||
Coord: { Vec: number[]; Error: number; Adjustment: number; Height: number };
|
||||
}
|
||||
|
||||
export type ConsulOperatorReadKind = "autopilot_configuration" | "autopilot_health" | "autopilot_state" | "raft_configuration" | "usage" | "license" | "audit";
|
||||
export interface ConsulOperatorDocument {
|
||||
kind: string;
|
||||
fields: Array<{ name: string; value: string }>;
|
||||
}
|
||||
export interface ConsulAutopilotUpdate {
|
||||
CleanupDeadServers?: boolean | null;
|
||||
LastContactThreshold?: string | null;
|
||||
MaxTrailingLogs?: number | null;
|
||||
MinQuorum?: number | null;
|
||||
ServerStabilizationTime?: string | null;
|
||||
RedundancyZoneTag?: string | null;
|
||||
DisableUpgradeMigration?: boolean | null;
|
||||
UpgradeVersionTag?: string | null;
|
||||
}
|
||||
export interface ConsulSnapshot {
|
||||
dataBase64: string;
|
||||
sizeBytes: number;
|
||||
datacenter: string;
|
||||
}
|
||||
export interface ConsulSnapshotRestoreRequest {
|
||||
snapshotBase64: string;
|
||||
targetDatacenter: string;
|
||||
confirmation: string;
|
||||
}
|
||||
export interface ConsulRaftWriteRequest {
|
||||
serverId: string | null;
|
||||
address: string | null;
|
||||
confirmation: string;
|
||||
}
|
||||
export interface ConsulKeyringWriteRequest {
|
||||
operation: "install" | "use" | "remove";
|
||||
key: string;
|
||||
confirmation: string;
|
||||
}
|
||||
export interface ConsulLicenseWriteRequest {
|
||||
license: string;
|
||||
confirmation: string;
|
||||
}
|
||||
|
||||
export interface ConsulDeletePrefixReport {
|
||||
items: ConsulDeleteResultItem[];
|
||||
succeeded: number;
|
||||
conflicted: number;
|
||||
failed: number;
|
||||
atomic: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulResponseMetadata {
|
||||
index: KvInt64 | null;
|
||||
filteredByAcls: boolean | null;
|
||||
knownLeader: boolean | null;
|
||||
lastContact: KvInt64 | null;
|
||||
queryBackend: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulListResponse<T> {
|
||||
items: T;
|
||||
metadata: ConsulResponseMetadata;
|
||||
}
|
||||
|
||||
export interface ConsulReadOptions {
|
||||
filter?: string | null;
|
||||
near?: string | null;
|
||||
index?: string | null;
|
||||
wait?: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulAgentIdentity {
|
||||
node: string;
|
||||
address: string;
|
||||
datacenter: string;
|
||||
version: string | null;
|
||||
server: boolean | null;
|
||||
revision: string | null;
|
||||
segment: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulAgentMember {
|
||||
Name: string;
|
||||
Addr: string;
|
||||
Port: number;
|
||||
Tags: Record<string, string>;
|
||||
Status: number;
|
||||
}
|
||||
|
||||
export interface ConsulCatalogNode {
|
||||
ID: string;
|
||||
Node: string;
|
||||
Address: string;
|
||||
Datacenter: string;
|
||||
TaggedAddresses: Record<string, string>;
|
||||
NodeMeta: Record<string, string>;
|
||||
CreateIndex: number;
|
||||
ModifyIndex: number;
|
||||
}
|
||||
|
||||
export interface ConsulServiceAddress {
|
||||
Address: string;
|
||||
Port: number;
|
||||
}
|
||||
|
||||
export interface ConsulServiceWeights {
|
||||
Passing: number;
|
||||
Warning: number;
|
||||
}
|
||||
|
||||
export interface ConsulCatalogServiceNode {
|
||||
ID: string;
|
||||
Node: string;
|
||||
Address: string;
|
||||
Datacenter: string;
|
||||
TaggedAddresses: Record<string, string>;
|
||||
NodeMeta: Record<string, string>;
|
||||
ServiceKind: string;
|
||||
ServiceID: string;
|
||||
ServiceName: string;
|
||||
ServiceTags: string[];
|
||||
ServiceAddress: string;
|
||||
ServicePort: number;
|
||||
ServiceMeta: Record<string, string>;
|
||||
ServiceTaggedAddresses: Record<string, ConsulServiceAddress>;
|
||||
ServiceWeights: ConsulServiceWeights;
|
||||
CreateIndex: number;
|
||||
ModifyIndex: number;
|
||||
}
|
||||
|
||||
export interface ConsulCatalogService {
|
||||
ID: string;
|
||||
Service: string;
|
||||
Tags: string[];
|
||||
Address: string;
|
||||
TaggedAddresses: Record<string, ConsulServiceAddress>;
|
||||
Meta: Record<string, string>;
|
||||
Port: number;
|
||||
Weights: ConsulServiceWeights;
|
||||
}
|
||||
|
||||
export interface ConsulNodeServices {
|
||||
Node: ConsulCatalogNode;
|
||||
Services: Record<string, ConsulCatalogService>;
|
||||
}
|
||||
|
||||
export interface ConsulHealthCheckDefinition {
|
||||
HTTP: string;
|
||||
TCP: string;
|
||||
GRPC: string;
|
||||
Interval: string;
|
||||
Timeout: string;
|
||||
TTL: string;
|
||||
}
|
||||
|
||||
export interface ConsulHealthCheck {
|
||||
Node: string;
|
||||
CheckID: string;
|
||||
Name: string;
|
||||
Status: "passing" | "warning" | "critical" | string;
|
||||
Notes: string;
|
||||
Output: string;
|
||||
ServiceID: string;
|
||||
ServiceName: string;
|
||||
ServiceTags: string[];
|
||||
Type: string;
|
||||
ExposedPort: number;
|
||||
Definition: ConsulHealthCheckDefinition;
|
||||
CreateIndex: number;
|
||||
ModifyIndex: number;
|
||||
Maintenance: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulHealthService {
|
||||
ID: string;
|
||||
Service: string;
|
||||
Tags: string[];
|
||||
Address: string;
|
||||
TaggedAddresses: Record<string, ConsulServiceAddress>;
|
||||
Meta: Record<string, string>;
|
||||
Port: number;
|
||||
Weights: ConsulServiceWeights;
|
||||
Kind: string;
|
||||
}
|
||||
|
||||
export interface ConsulServiceInstance {
|
||||
Node: ConsulCatalogNode;
|
||||
Service: ConsulHealthService;
|
||||
Checks: ConsulHealthCheck[];
|
||||
}
|
||||
|
||||
export interface ConsulAgentService {
|
||||
Kind: string;
|
||||
ID: string;
|
||||
Service: string;
|
||||
Tags: string[];
|
||||
Meta: Record<string, string>;
|
||||
Port: number;
|
||||
Address: string;
|
||||
TaggedAddresses: Record<string, ConsulServiceAddress>;
|
||||
Weights: ConsulServiceWeights;
|
||||
EnableTagOverride: boolean;
|
||||
Datacenter: string;
|
||||
}
|
||||
|
||||
export interface ConsulAgentProxyRegistration {
|
||||
destinationServiceName: string;
|
||||
destinationServiceId: string;
|
||||
localServiceAddress: string;
|
||||
localServicePort: number;
|
||||
localServicePorts?: string[];
|
||||
localServiceSocketPath?: string;
|
||||
mode?: string;
|
||||
transparentProxy?: ConsulAgentTransparentProxy | null;
|
||||
config?: Record<string, unknown>;
|
||||
upstreams: ConsulAgentUpstream[];
|
||||
meshGateway?: ConsulAgentMeshGateway | null;
|
||||
expose?: ConsulAgentExpose | null;
|
||||
}
|
||||
|
||||
export interface ConsulAgentTransparentProxy {
|
||||
outboundListenerPort: number;
|
||||
dialedDirectly: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulAgentMeshGateway {
|
||||
mode: "" | "local" | "remote" | "none" | string;
|
||||
}
|
||||
|
||||
export interface ConsulAgentExposePath {
|
||||
path: string;
|
||||
protocol: "http" | "http2" | string;
|
||||
localPathPort: number;
|
||||
listenerPort: number;
|
||||
}
|
||||
|
||||
export interface ConsulAgentExpose {
|
||||
checks: boolean;
|
||||
paths: ConsulAgentExposePath[];
|
||||
}
|
||||
|
||||
export interface ConsulAgentUpstream {
|
||||
destinationType: "service" | "prepared_query" | string;
|
||||
destinationName: string;
|
||||
destinationPort?: number;
|
||||
destinationNamespace?: string;
|
||||
destinationPartition?: string;
|
||||
destinationPeer?: string;
|
||||
localBindAddress: string;
|
||||
localBindPort: number;
|
||||
localBindSocketPath?: string;
|
||||
localBindSocketMode?: string;
|
||||
datacenter: string;
|
||||
meshGateway?: ConsulAgentMeshGateway | null;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ConsulAgentServicePort {
|
||||
name: string;
|
||||
port: number;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulAgentConnectRegistration {
|
||||
native: boolean;
|
||||
sidecarService?: ConsulAgentServiceRegistration | null;
|
||||
}
|
||||
|
||||
export type ConsulAgentCheckDefinition =
|
||||
| { type: "http"; url: string; method: string; interval: string; timeout: string; tlsSkipVerify: boolean }
|
||||
| { type: "tcp"; address: string; interval: string; timeout: string }
|
||||
| { type: "grpc"; address: string; interval: string; timeout: string; tls: boolean }
|
||||
| { type: "ttl"; ttl: string }
|
||||
| { type: "docker"; containerId: string; shell: string; args: string[]; interval: string; timeout: string }
|
||||
| { type: "script"; args: string[]; interval: string; timeout: string };
|
||||
|
||||
export interface ConsulAgentCheckRegistration {
|
||||
id: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
serviceId: string;
|
||||
status: string;
|
||||
definition: ConsulAgentCheckDefinition;
|
||||
}
|
||||
|
||||
export interface ConsulAgentServiceRegistration {
|
||||
id: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
address: string;
|
||||
port: number;
|
||||
meta: Record<string, string>;
|
||||
weights: ConsulServiceWeights;
|
||||
kind: string;
|
||||
enableTagOverride: boolean;
|
||||
taggedAddresses?: Record<string, ConsulServiceAddress>;
|
||||
ports?: ConsulAgentServicePort[];
|
||||
proxy: ConsulAgentProxyRegistration | null;
|
||||
connect?: ConsulAgentConnectRegistration | null;
|
||||
checks: ConsulAgentCheckRegistration[];
|
||||
}
|
||||
|
||||
export interface ConsulAgentWriteResult {
|
||||
target: ConsulAgentIdentity;
|
||||
}
|
||||
|
||||
export type ConsulCheckStatus = "passing" | "warning" | "critical";
|
||||
|
||||
export interface ConsulSession {
|
||||
ID: string;
|
||||
Name: string;
|
||||
Node: string;
|
||||
LockDelay: number;
|
||||
Behavior: "release" | "delete" | string;
|
||||
TTL: string;
|
||||
NodeChecks: string[];
|
||||
ServiceChecks: ConsulSessionServiceCheck[];
|
||||
Namespace: string;
|
||||
Partition: string;
|
||||
CreateIndex: number;
|
||||
ModifyIndex: number;
|
||||
}
|
||||
|
||||
export interface ConsulSessionServiceCheck {
|
||||
ID: string;
|
||||
Namespace: string;
|
||||
}
|
||||
|
||||
export interface ConsulSessionCreateRequest {
|
||||
name: string;
|
||||
node: string;
|
||||
lockDelay?: string | null;
|
||||
behavior: "release" | "delete";
|
||||
ttl?: string | null;
|
||||
nodeChecks: string[];
|
||||
serviceChecks: ConsulSessionServiceCheck[];
|
||||
}
|
||||
|
||||
export interface ConsulSessionHeldKey {
|
||||
key: string;
|
||||
modifyIndex: KvInt64;
|
||||
}
|
||||
export interface ConsulSessionKeysResponse {
|
||||
items: ConsulKvRecord[];
|
||||
complete: boolean;
|
||||
filteredByAcls: boolean;
|
||||
}
|
||||
export interface ConsulSessionDestroyImpact {
|
||||
session: ConsulSession;
|
||||
heldKeys: ConsulSessionHeldKey[];
|
||||
complete: boolean;
|
||||
filteredByAcls: boolean;
|
||||
}
|
||||
export interface ConsulSessionDestroyRequest {
|
||||
id: string;
|
||||
expectedBehavior: string;
|
||||
expectedHeldKeys: ConsulSessionHeldKey[];
|
||||
}
|
||||
|
||||
export interface ConsulLockRequest {
|
||||
key: string;
|
||||
session: string;
|
||||
value: KvValue;
|
||||
flags?: KvInt64 | null;
|
||||
expectedModifyIndex?: KvInt64 | null;
|
||||
}
|
||||
|
||||
export interface ConsulLockResponse {
|
||||
acquired: boolean;
|
||||
key: string;
|
||||
session: string;
|
||||
}
|
||||
|
||||
export type ConsulAclKind = "token" | "policy" | "role" | "authMethod" | "bindingRule" | "templatedPolicy";
|
||||
export interface ConsulAclLink {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
}
|
||||
export interface ConsulAclToken {
|
||||
AccessorID?: string;
|
||||
SecretID?: string;
|
||||
Description?: string;
|
||||
Local?: boolean;
|
||||
AuthMethod?: string;
|
||||
Policies?: ConsulAclLink[];
|
||||
Roles?: ConsulAclLink[];
|
||||
ServiceIdentities?: unknown[];
|
||||
NodeIdentities?: unknown[];
|
||||
TemplatedPolicies?: unknown[];
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulAclPolicy {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
Rules?: string;
|
||||
Datacenters?: string[];
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulAclRole {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
Policies?: ConsulAclLink[];
|
||||
ServiceIdentities?: unknown[];
|
||||
NodeIdentities?: unknown[];
|
||||
TemplatedPolicies?: unknown[];
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulAclAuthMethod {
|
||||
Name?: string;
|
||||
Type?: string;
|
||||
DisplayName?: string;
|
||||
Description?: string;
|
||||
MaxTokenTTL?: string;
|
||||
TokenLocality?: string;
|
||||
Config?: unknown;
|
||||
}
|
||||
export interface ConsulAclBindingRule {
|
||||
ID?: string;
|
||||
Description?: string;
|
||||
AuthMethod?: string;
|
||||
Selector?: string;
|
||||
BindType?: string;
|
||||
BindName?: string;
|
||||
BindVars?: unknown;
|
||||
}
|
||||
export interface ConsulAclTemplatedPolicy {
|
||||
TemplateName?: string;
|
||||
Schema?: unknown;
|
||||
}
|
||||
export type ConsulAclList =
|
||||
| { kind: "token"; items: ConsulAclToken[] }
|
||||
| { kind: "policy"; items: ConsulAclPolicy[] }
|
||||
| { kind: "role"; items: ConsulAclRole[] }
|
||||
| { kind: "authMethod"; items: ConsulAclAuthMethod[] }
|
||||
| { kind: "bindingRule"; items: ConsulAclBindingRule[] }
|
||||
| { kind: "templatedPolicy"; items: ConsulAclTemplatedPolicy[] };
|
||||
export type ConsulAclItem =
|
||||
| { kind: "token"; item: ConsulAclToken }
|
||||
| { kind: "policy"; item: ConsulAclPolicy }
|
||||
| { kind: "role"; item: ConsulAclRole }
|
||||
| { kind: "authMethod"; item: ConsulAclAuthMethod }
|
||||
| { kind: "bindingRule"; item: ConsulAclBindingRule }
|
||||
| { kind: "templatedPolicy"; item: ConsulAclTemplatedPolicy };
|
||||
export type ConsulAclWrite = Exclude<ConsulAclItem, { kind: "templatedPolicy" }>;
|
||||
export interface ConsulAclReferences {
|
||||
tokenAccessorIds: string[];
|
||||
roleIds: string[];
|
||||
bindingRuleIds: string[];
|
||||
complete: boolean;
|
||||
filteredByAcls: boolean;
|
||||
}
|
||||
|
||||
export type ConsulEnterpriseKind = "namespace" | "partition";
|
||||
export interface ConsulNamespaceAcls {
|
||||
PolicyDefaults?: unknown[];
|
||||
RoleDefaults?: unknown[];
|
||||
}
|
||||
export interface ConsulNamespace {
|
||||
Name?: string;
|
||||
Partition?: string;
|
||||
Description?: string;
|
||||
Meta?: Record<string, string>;
|
||||
ACLs?: ConsulNamespaceAcls;
|
||||
DeletedAt?: string | null;
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulPartition {
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
Meta?: Record<string, string>;
|
||||
DeletedAt?: string | null;
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export type ConsulEnterpriseList = { kind: "namespace"; items: ConsulNamespace[] } | { kind: "partition"; items: ConsulPartition[] };
|
||||
export type ConsulEnterpriseItem = { kind: "namespace"; item: ConsulNamespace } | { kind: "partition"; item: ConsulPartition };
|
||||
export type ConsulEnterpriseWrite = ConsulEnterpriseItem;
|
||||
export interface ConsulScopeImpact {
|
||||
services: number;
|
||||
nodes: number;
|
||||
kvKeys: number;
|
||||
healthChecks: number;
|
||||
sessions: number;
|
||||
configEntries: number;
|
||||
intentions: number;
|
||||
peerings: number;
|
||||
namespaces: number;
|
||||
aclTokens: number;
|
||||
aclPolicies: number;
|
||||
aclRoles: number;
|
||||
aclAuthMethods: number;
|
||||
aclBindingRules: number;
|
||||
complete: boolean;
|
||||
filteredByAcls: boolean;
|
||||
unavailableResources: string[];
|
||||
}
|
||||
|
||||
export interface ConsulConfigEntry {
|
||||
kind: string;
|
||||
name: string;
|
||||
modifyIndex: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
export interface ConsulConfigEntryApply {
|
||||
kind: string;
|
||||
name: string;
|
||||
expectedModifyIndex: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
export interface ConsulIntention {
|
||||
ID?: string;
|
||||
Description?: string;
|
||||
SourceName?: string;
|
||||
DestinationName?: string;
|
||||
SourceNamespace?: string;
|
||||
DestinationNamespace?: string;
|
||||
SourcePartition?: string;
|
||||
DestinationPartition?: string;
|
||||
Action?: string;
|
||||
Permissions?: unknown[];
|
||||
Precedence?: number;
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulIntentionMatchRequest {
|
||||
by: "source" | "destination";
|
||||
name: string;
|
||||
}
|
||||
export interface ConsulIntentionExactRequest {
|
||||
source: string;
|
||||
destination: string;
|
||||
}
|
||||
export interface ConsulIntentionCheckRequest {
|
||||
source: string;
|
||||
destination: string;
|
||||
}
|
||||
export interface ConsulIntentionCheckResponse {
|
||||
Allowed: boolean;
|
||||
}
|
||||
export interface ConsulDiscoveryChain {
|
||||
ServiceName?: string;
|
||||
Namespace?: string;
|
||||
Partition?: string;
|
||||
Datacenter?: string;
|
||||
Protocol?: string;
|
||||
StartNode?: string;
|
||||
Nodes?: Record<string, unknown>;
|
||||
Targets?: Record<string, unknown>;
|
||||
}
|
||||
export interface ConsulPeering {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
Partition?: string;
|
||||
State?: string;
|
||||
PeerID?: string;
|
||||
PeerServerName?: string;
|
||||
PeerServerAddresses?: string[];
|
||||
ImportedServices?: string[];
|
||||
ExportedServices?: string[];
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulPeeringGenerateRequest {
|
||||
PeerName: string;
|
||||
Partition?: string;
|
||||
Meta?: unknown;
|
||||
}
|
||||
export interface ConsulPeeringToken {
|
||||
PeeringToken: string;
|
||||
}
|
||||
export interface ConsulPeeringEstablishRequest {
|
||||
PeerName: string;
|
||||
PeeringToken: string;
|
||||
Partition?: string;
|
||||
}
|
||||
export interface ConsulExportedService {
|
||||
Service?: string;
|
||||
Namespace?: string;
|
||||
Partition?: string;
|
||||
Consumers?: unknown[];
|
||||
}
|
||||
@@ -71,7 +71,8 @@ export type DatabaseType =
|
||||
| "jdbc"
|
||||
| "mq"
|
||||
| "mqtt"
|
||||
| "nacos";
|
||||
| "nacos"
|
||||
| "consul";
|
||||
|
||||
export function isElasticsearchCompatibleDatabaseType(dbType?: DatabaseType): boolean {
|
||||
return dbType === "elasticsearch" || dbType === "easysearch";
|
||||
@@ -841,6 +842,8 @@ export type TreeNodeType =
|
||||
| "etcd-dashboard"
|
||||
| "etcd-access-control"
|
||||
| "zookeeper-root"
|
||||
| "consul-root"
|
||||
| "consul-overview"
|
||||
| "mongo-db"
|
||||
| "mongo-gridfs"
|
||||
| "mongo-buckets"
|
||||
@@ -1045,6 +1048,8 @@ export interface QueryTab {
|
||||
| "etcd-dashboard"
|
||||
| "etcd-access-control"
|
||||
| "zookeeper"
|
||||
| "consul"
|
||||
| "consul-overview"
|
||||
| "mq"
|
||||
| "mqtt"
|
||||
| "nacos"
|
||||
|
||||
Reference in new issue
Block a user