diff --git a/.github/scripts/database-issue-catalog.mjs b/.github/scripts/database-issue-catalog.mjs index 350d17d7ce..9f6ee6022e 100644 --- a/.github/scripts/database-issue-catalog.mjs +++ b/.github/scripts/database-issue-catalog.mjs @@ -69,6 +69,7 @@ const extraAliases = { const supplementalDrivers = [ { dbType: "turso", label: "Turso", aliases: ["libsql", "lib sql"] }, { dbType: "nacos", label: "Nacos", aliases: ["r-nacos", "rnacos"] }, + { dbType: "consul", label: "Consul", aliases: ["hashicorp consul"] }, { dbType: "cloudberry", label: "Apache Cloudberry" }, { dbType: "mariadb", label: "MariaDB" }, { dbType: "tidb", label: "TiDB" }, diff --git a/.github/scripts/i18n-autofill.mjs b/.github/scripts/i18n-autofill.mjs index ca6116f4f5..50c4d202a1 100644 --- a/.github/scripts/i18n-autofill.mjs +++ b/.github/scripts/i18n-autofill.mjs @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; const LOCALES_DIR = "apps/desktop/src/i18n/locales"; const SOURCE_LOCALE = "zh-CN"; @@ -351,27 +351,98 @@ function applyEdits(text, edits) { function flattenLeaves(root) { const result = new Map(); - flattenNode(root, [], result); + flattenNode(root, [], result, root.externalObjects || new Map(), new Set()); return result; } -function flattenNode(node, path, result) { +function flattenNode(node, path, result, externalObjects, activeExternalObjects) { for (const property of node.properties) { const nextPath = [...path, property.key]; if (property.value.type === "object") { - flattenNode(property.value, nextPath, result); + flattenNode(property.value, nextPath, result, externalObjects, activeExternalObjects); } else if (property.value.type === "string") { result.set(nextPath.join("."), property.value.value); + } else if (property.value.type === "spread") { + const [binding, ...members] = property.value.expression.split("."); + let spreadObject = externalObjects.get(binding); + for (const member of members) { + spreadObject = spreadObject?.properties.find((candidate) => candidate.key === member && candidate.value.type === "object")?.value; + } + flattenExternalObject(property.value.expression, spreadObject, path, result, externalObjects, activeExternalObjects); + } else if (property.value.type === "external") { + flattenExternalObject(property.key, externalObjects.get(property.key), nextPath, result, externalObjects, activeExternalObjects); } } } +function flattenExternalObject(name, object, path, result, externalObjects, activeExternalObjects) { + if (!object || activeExternalObjects.has(name)) return; + activeExternalObjects.add(name); + flattenNode(object, path, result, externalObjects, activeExternalObjects); + activeExternalObjects.delete(name); +} + function parseLocaleFile(text, file) { const parser = new Parser(text, file); const root = parser.parseLocaleRoot(); + root.externalObjects = parseImportedObjects(text, file); + for (const [name, object] of parseDeclaredObjects(text, file)) { + root.externalObjects.set(name, object); + } return { root }; } +function parseDeclaredObjects(text, file) { + const result = new Map(); + const declarations = /^\s*const\s+([A-Za-z_$][\w$]*)\s*=/gm; + const exportIndex = text.indexOf("export default"); + + for (const match of text.slice(0, exportIndex).matchAll(declarations)) { + const parser = new Parser(text, file); + parser.index = match.index + match[0].length; + parser.skipSpace(); + if (parser.peek() === "{") result.set(match[1], parser.parseObject()); + } + + return result; +} + +function parseImportedObjects(text, file, visited = new Set()) { + const result = new Map(); + const actualFile = existsSync(file) ? file : file.slice(file.indexOf(":") + 1); + const resolvedFile = resolve(actualFile); + if (visited.has(resolvedFile)) return result; + visited.add(resolvedFile); + const importPattern = /import\s*\{([^}]+)\}\s*from\s*["'](\.\.?\/[^"']+)["'];?/g; + + for (const match of text.matchAll(importPattern)) { + const modulePath = resolve(dirname(actualFile), `${match[2]}.ts`); + if (!existsSync(modulePath)) continue; + const moduleText = readFileSync(modulePath, "utf8"); + for (const [name, object] of parseImportedObjects(moduleText, modulePath, visited)) { + if (!result.has(name)) result.set(name, object); + } + for (const specifier of match[1].split(",")) { + const [exportedName, localName = exportedName] = specifier.trim().split(/\s+as\s+/); + if (!exportedName) continue; + const object = parseExportedObject(moduleText, modulePath, exportedName); + if (object) result.set(localName, object); + } + } + + return result; +} + +function parseExportedObject(text, file, name) { + const match = new RegExp(`export\\s+const\\s+${name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*=`).exec(text); + if (!match) return null; + const parser = new Parser(text, file); + parser.index = match.index + match[0].length; + parser.skipSpace(); + if (parser.peek() !== "{") return null; + return parser.parseObject(); +} + class Parser { constructor(text, file) { this.text = text; @@ -412,6 +483,26 @@ class Parser { } const start = this.index; + if (this.text.startsWith("...", this.index)) { + this.index += 3; + this.skipSpace(); + const root = this.parseIdentifier(); + if (!root) this.fail("Expected spread identifier"); + let expression = root; + while (this.peek() === ".") { + this.index += 1; + const member = this.parseIdentifier(); + if (!member) this.fail("Expected spread member"); + expression += `.${member}`; + } + this.skipSpace(); + const hasComma = this.peek() === ","; + if (hasComma) this.index += 1; + else if (this.peek() !== "}") this.fail("Expected comma after spread property"); + properties.push({ key: `...${expression}`, start, end: this.index, hasComma, value: { type: "spread", expression } }); + continue; + } + const key = this.parseKey(); this.skipSpace(); if (this.peek() === "," || this.peek() === "}") { diff --git a/.github/scripts/label-issue-database.test.mjs b/.github/scripts/label-issue-database.test.mjs index 782eccf7c1..4ff5a204b9 100644 --- a/.github/scripts/label-issue-database.test.mjs +++ b/.github/scripts/label-issue-database.test.mjs @@ -46,6 +46,7 @@ test("labels native and compatibility database products with their families", () ["Turso", ["db/turso"]], ["Nacos 2.0.1", ["db/nacos"]], ["r-nacos", ["db/nacos"]], + ["HashiCorp Consul", ["db/consul"]], ["Apache Cloudberry", ["db/cloudberry", "db/postgres"]], ["TiDB v8.5", ["db/mysql", "db/tidb"]], ["OceanBase", ["db/oceanbase"]], diff --git a/README.md b/README.md index 394161b18d..239d646219 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ ### 70+ Databases, One Tool -MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium. +MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium. ### Query Editor @@ -483,7 +483,7 @@ DBX is 20 MB with no runtime dependencies for its native database features (no s What databases are supported? -MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent-based profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported. +MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent-based profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3cd61555d1..0e73070d74 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,7 +90,7 @@ ### 70+ 种数据库,一个工具搞定 -MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。 +MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。 ### 查询编辑器 @@ -486,7 +486,7 @@ DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 支持哪些数据库? -MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。 +MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。 diff --git a/apps/desktop/public/icons/database/consul.svg b/apps/desktop/public/icons/database/consul.svg new file mode 100644 index 0000000000..5220a1e9dd --- /dev/null +++ b/apps/desktop/public/icons/database/consul.svg @@ -0,0 +1,7 @@ + + + favicon + + + + diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index c5f2bff85f..16b229c6fe 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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") { diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 280196e4c6..a0543f45f2 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -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("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("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); } +function resetConsulFields(value?: Record) { + 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) : 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) : undefined); +} + function resetMqttFields(config?: Partial) { mqttHost.value = config?.host?.trim() || "127.0.0.1"; mqttPort.value = config?.port || 1883; @@ -1685,6 +1728,46 @@ function buildNacosAdminConfig(): NacosAdminConfig { }; } +function buildConsulExternalConfig(): Record { + 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 = { 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([ "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) { - + {{ t("connection.connectionUrlOptional") }} @@ -6174,6 +6288,80 @@ function openExternalUrl(url: string) { + + + + {{ t("connection.consulAddress") }} + + + + {{ t("connection.consulToken") }} + + + + {{ t("connection.consulDatacenter") }} + + + + {{ t("connection.consulNamespace") }} + + + + {{ t("connection.consulPartition") }} + + + + {{ t("connection.consulAgentTargetNode") }} + + + + {{ t("connection.consulAgentTargetAddress") }} + + + {{ t("connection.consulAgentTargetHint") }} + + + + {{ t("connection.consulConsistency") }} + + + + {{ t("connection.consulConsistencyDefault") }} + {{ t("connection.consulConsistencyStale") }} + {{ t("connection.consulConsistencyConsistent") }} + + + + + {{ t("connection.consulTlsSkipVerify") }} + + + {{ t("connection.consulTlsSkipVerifyHint") }} + + + + {{ t("connection.consulMeshFeatures") }} + + + {{ t("connection.consulMeshVisible") }} + {{ t("connection.consulMeshVisibleHint") }} + + + + + + {{ t("connection.consulOperatorWrites") }} + + {{ t("connection.consulOperatorVisible") }} + {{ t("connection.consulOperatorSnapshotRestore") }} + {{ t("connection.consulOperatorAutopilot") }} + {{ t("connection.consulOperatorRaft") }} + {{ t("connection.consulOperatorKeyring") }} + {{ t("connection.consulOperatorLicense") }} + + + + @@ -6997,7 +7185,7 @@ function openExternalUrl(url: string) { - + SSL/TLS @@ -7050,7 +7238,7 @@ function openExternalUrl(url: string) { - + diff --git a/apps/desktop/src/components/consul/ConsulAcl.vue b/apps/desktop/src/components/consul/ConsulAcl.vue new file mode 100644 index 0000000000..5f0ec7b172 --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulAcl.vue @@ -0,0 +1,296 @@ + + + + + + + {{ tab.label }} + + {{ t("consul.ui.create") }} + + + + {{ t("consul.ui.capability", { status: capabilityLabel(capability) }) }} + {{ t("consul.ui.loading") }} + {{ error }} + + + + {{ itemLabel(item) }} + {{ itemId(item) }} + + + + {{ t("consul.ui.noResources") }} + + + + + {{ secretRevealed ? secretOnce : "****************" }}{{ t("consul.ui.copyOnce") }}{{ t("consul.ui.hide") }} + + + {{ editorId ? t("consul.ui.editResource", { kind: activeLabel }) : t("consul.ui.createResource", { kind: activeLabel }) }} + + {{ t("consul.ui.knownImpact", { tokens: editReferences.tokenAccessorIds.length, roles: editReferences.roleIds.length, bindingRules: editReferences.bindingRuleIds.length }) }}{{ t("consul.ui.incomplete") }} + + {{ t("consul.ui.cancel") }}{{ t("consul.ui.save") }} + + { + if (!value) closeDelete(); + } + " + >{{ t("consul.ui.deleteResource", { label: pendingDelete?.label }) }} + + {{ t("consul.ui.tokenReferences", { tokens: references.tokenAccessorIds.length, roles: references.roleIds.length, bindingRules: references.bindingRuleIds.length }) }} + {{ t("consul.ui.impactBlocked") }} + + {{ t("consul.ui.cancel") }}{{ t("consul.ui.delete") }} + diff --git a/apps/desktop/src/components/consul/ConsulHealth.vue b/apps/desktop/src/components/consul/ConsulHealth.vue new file mode 100644 index 0000000000..373a8e5c13 --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulHealth.vue @@ -0,0 +1,666 @@ + + + + + + + {{ t("consul.ui.healthChecks") }} + + {{ + t("consul.ui.agentTarget", { target: canAgentWrite && agentTarget ? `${agentTarget.node} (${agentTarget.address})` : t("consul.ui.unknownWritesDisabled") }) + }} + {{ watchPaused ? t("consul.ui.watchPaused") : t("consul.ui.watchingIndex", { index: watchIndex || "1" }) }} + + + + {{ watchEnabled && !watchPaused ? t("consul.ui.pauseWatch") : t("consul.ui.startWatch") }} + + {{ t("consul.ui.register") }} + + + + + + + {{ t("consul.ui.state") }} + {{ t("consul.ui.node") }} + {{ t("consul.ui.service") }} + + + {{ t("consul.ui.allStates") }} + {{ t("consul.ui.passing") }} + {{ t("consul.ui.warning") }} + {{ t("consul.ui.critical") }} + + + + {{ t("consul.ui.query") }} + + + + + + + + {{ t("consul.ui.allStates") }} + {{ t("consul.ui.passing") }} + {{ t("consul.ui.warning") }} + {{ t("consul.ui.critical") }} + {{ t("consul.ui.maintenance") }} + + + {{ t("consul.ui.allCheckTypes") }} + {{ checkTypeLabel(type) }} + + {{ t("consul.ui.clearFilters") }} + + + {{ t("consul.ui.healthResultCount", { filtered: filteredChecks.length, total: checks.length }) }} + + {{ t("consul.ui.passing") }} {{ healthSummary.passing }} + + + {{ t("consul.ui.warning") }} {{ healthSummary.warning }} + + + {{ t("consul.ui.critical") }} {{ healthSummary.critical }} + + + {{ t("consul.ui.maintenance") }} {{ healthSummary.maintenance }} + + + + + + {{ name }}: {{ error }} + + + + + {{ t("consul.ui.register") }} + {{ t("consul.ui.checkRegistrationHint", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }} + + + + {{ t("consul.ui.name") }} + + + {{ t("consul.ui.id") }} + + + {{ t("consul.ui.serviceId") }} + + + {{ t("consul.ui.type") }} + {{ t("consul.ui.ttl") }} + {{ t("consul.ui.http") }} + {{ t("consul.ui.tcp") }} + {{ t("consul.ui.grpc") }} + {{ t("consul.ui.docker") }} + {{ t("consul.ui.script") }} + + + + {{ t("consul.ui.ttl") }} + + + {{ t("consul.ui.arguments") }} + + + {{ form.type === "docker" ? t("consul.ui.containerId") : t("consul.ui.endpoint") }} + + + + {{ t("consul.ui.interval") }} + + + {{ t("consul.ui.timeout") }} + + + {{ t("consul.ui.commandArguments") }} + + + + + {{ t("consul.ui.cancel") }}{{ t("consul.ui.registerOn", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }} + + + + + + {{ browseMode === "state" ? t("consul.ui.clusterHealth") : browseMode === "node" ? t("consul.ui.nodeHealth", { node: browseName || "-" }) : t("consul.ui.serviceHealth", { service: browseName || "-" }) }}{{ filteredChecks.length }} + + + + + + + + {{ check.Name }}{{ statusLabel(check) }}{{ checkTypeLabel(checkType(check)) }} + {{ check.CheckID }} + + + {{ check.ServiceID ? t("consul.ui.service") : t("consul.ui.node") }} + {{ check.ServiceName || check.Node }} + {{ check.Node }} + + + {{ t("consul.ui.output") }} + {{ check.Output || check.Notes || "-" }} + + + + {{ t("consul.ui.noMatchingHealthChecks") }} + {{ t("consul.ui.loading") }} + + + + {{ t("consul.ui.checkDetails", { check: selectedCheck.Name }) }} + + + + {{ t("consul.ui.checkId") }} + {{ selectedCheck.CheckID }} + + + {{ t("consul.ui.serviceId") }} + {{ selectedCheck.ServiceID || "-" }} + + + {{ t("consul.ui.notes") }} + {{ selectedCheck.Notes || "-" }} + + + {{ t("consul.ui.output") }} + {{ selectedCheck.Output || "-" }} + + + {{ t("consul.ui.definition") }} + {{ JSON.stringify(selectedCheck.Definition) }} + + + {{ t("consul.ui.indexes") }} + {{ selectedCheck.CreateIndex }} / {{ selectedCheck.ModifyIndex }} + + + + + + + {{ t("consul.ui.serviceInstances") }}{{ serviceInstances.length }} + + + + + {{ instance.Node.Node }}{{ instance.Service.Address || instance.Node.Address }}:{{ instance.Service.Port }} · {{ instance.Service.ID }} + + + + + + {{ t("consul.ui.localAgentChecks") }}{{ filteredLocalChecks.length }} / {{ localCheckItems.length }} + + + + + + + + + {{ check.Name }}{{ statusLabel(check) }}{{ checkTypeLabel(checkType(check)) }} + + {{ check.CheckID }} + + + {{ check.ServiceID ? t("consul.ui.service") : t("consul.ui.node") }} + {{ check.ServiceName || check.Node }} + {{ check.Node }} + + + {{ t("consul.ui.output") }} + {{ check.Output || check.Notes || "-" }} + + + + + + + + {{ t("consul.ui.noMatchingHealthChecks") }} + + + diff --git a/apps/desktop/src/components/consul/ConsulKeyBrowser.vue b/apps/desktop/src/components/consul/ConsulKeyBrowser.vue new file mode 100644 index 0000000000..3ca55fe3b0 --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulKeyBrowser.vue @@ -0,0 +1,1024 @@ + + + + + + + + {{ t("consul.tools.search") }} + {{ t("consul.tools.exportPrefix") }} + {{ t("consul.tools.import") }} + {{ t("consul.tools.migrate") }} + + + + + + {{ t("consul.tools.searchTitle") }} + + + + + + + {{ t("consul.tools.searchAll") }} + {{ t("consul.tools.searchKeys") }} + {{ t("consul.tools.searchValues") }} + + + {{ t("consul.tools.search") }} + + {{ t("consul.tools.caseSensitive") }} + + {{ t("consul.tools.scanned", { count: searchScanned }) }} + {{ t("common.cancel") }} + + {{ searchError }} + {{ t("consul.aclFiltered") }} + {{ t("consul.tools.searchLimited") }} + + + {{ result.key }} + {{ t("consul.ui.key") }} + {{ t("consul.ui.value") }} + + {{ t("consul.tools.noSearchResults") }} + + + {{ t("consul.tools.searchSummary", { matched: searchMatched, scanned: searchScanned }) }} + + + + {{ t("consul.tools.exportSearchResults") }} + + + + + + + + {{ t("consul.tools.exportPrefix") }} + + {{ t("consul.tools.prefix") }} + + + + + + + {{ suggestion.key }} + + + + {{ t("consul.tools.exportLimitHint") }} + + + {{ t("common.cancel") }} + {{ t("consul.export") }} + + + + + + + {{ transferMode === "migration" ? t("consul.tools.migrateTitle") : t("consul.tools.importTitle") }} + + + {{ t("consul.tools.sourcePrefix") }} + + + + + + + {{ suggestion.key }} + + + + + + {{ t("consul.tools.targetConnection") }} + + + {{ connection.name }} + + + + {{ t("consul.tools.conflictPolicy") }} + + + + {{ t("consul.tools.policyAbort") }} + {{ t("consul.tools.policySkip") }} + {{ t("consul.tools.policyCas") }} + + + + + + {{ t("consul.tools.bundleSummary", { count: transferBundle.entries.length, dc: transferBundle.source.datacenter || "-", ns: transferBundle.source.namespace || "-", partition: transferBundle.source.partition || "-" }) }} + + {{ t("consul.ui.clientSideMigration") }} + {{ t("consul.tools.targetReadOnly") }} + {{ transferError }} + + {{ t("consul.tools.creates", { count: transferPreview.creates }) }} + {{ t("consul.tools.updates", { count: transferPreview.updates }) }} + {{ t("consul.tools.unchanged", { count: transferPreview.unchanged }) }} + {{ t("consul.tools.skipped", { count: transferPreview.skipped }) }} + {{ t("consul.tools.conflicts", { count: transferPreview.conflicts }) }} + + + {{ transferReport.atomic ? t("consul.ui.atomicTransaction") : t("consul.ui.nonAtomicBatches", { count: reportBatchCount(transferReport.items) }) }} + {{ t("consul.ui.operationTotals", { succeeded: transferReport.succeeded, conflicted: transferReport.conflicted, skipped: transferReport.skipped, failed: transferReport.failed }) }} + + + + + {{ row.key }} + {{ t("consul.ui.batchOperation", { batch: row.batch, op: row.opIndex ?? "-" }) }} + {{ "message" in row ? row.message : "reason" in row ? row.reason : "" }} + + {{ t(`consul.tools.operation.${"outcome" in row ? row.outcome : row.operation}`) }} + + {{ t("consul.tools.loadingPreview") }} + + + {{ t("common.close") }} + {{ t("consul.tools.loadPreview") }} + {{ t("consul.tools.loadPreview") }} + {{ t("consul.tools.apply") }} + + + + + + + {{ t("consul.tools.deletePrefixTitle") }} + + + + + + + + + {{ suggestion.key }} + + + + + {{ t("consul.tools.preview") }} + + {{ t("consul.tools.deletePrefixWarning") }} + {{ deleteError }} + + {{ t("consul.tools.candidates", { count: deletePreview.candidates.length }) }} + {{ t("consul.tools.lockedCount", { count: deletePreview.locked }) }} + ACL {{ deletePreview.filteredByAcls ? t("consul.tools.filtered") : t("consul.tools.complete") }} + + + {{ deleteReport.atomic ? t("consul.ui.atomicTransaction") : t("consul.ui.nonAtomicBatches", { count: reportBatchCount(deleteReport.items) }) }} + {{ t("consul.ui.deleteOperationTotals", { succeeded: deleteReport.succeeded, conflicted: deleteReport.conflicted, failed: deleteReport.failed }) }} + + + + + {{ row.key }}{{ t("consul.ui.locked") }} + {{ t("consul.ui.sessionValue", { id: row.session }) }} + {{ t("consul.ui.batchOperation", { batch: row.batch, op: row.opIndex ?? "-" }) }} + {{ row.message }} + + {{ t(`consul.tools.operation.${row.outcome}`) }} + {{ row.modifyIndex }} + + + + {{ t("common.close") }} + {{ t("consul.tools.confirmDeletePrefix") }} + + + + + diff --git a/apps/desktop/src/components/consul/ConsulListPagination.vue b/apps/desktop/src/components/consul/ConsulListPagination.vue new file mode 100644 index 0000000000..5e7d279b97 --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulListPagination.vue @@ -0,0 +1,32 @@ + + + + + + + + {{ compact ? `${current} / ${pages}` : status }} + + + + + diff --git a/apps/desktop/src/components/consul/ConsulMesh.vue b/apps/desktop/src/components/consul/ConsulMesh.vue new file mode 100644 index 0000000000..cebfae8e19 --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulMesh.vue @@ -0,0 +1,403 @@ + + + + + {{ t("consul.ui.configEntries") }}{{ t("consul.ui.intentions") }}{{ t("consul.ui.discoveryChain") }}{{ t("consul.ui.peering") }}{{ t("consul.ui.exportedServices") }} + + {{ t("consul.ui.capability", { status: capabilityLabel(capability) }) }} + {{ error }} + {{ t("consul.ui.loading") }} + + {{ t("consul.ui.loadKind") }}{{ t("consul.ui.newJson") }} + + + + + {{ item.name }} + {{ item.kind }} · {{ t("consul.ui.modifyIndex", { index: item.modifyIndex }) }} + + {{ t("consul.ui.rawJson") }} + + + + {{ t("consul.ui.newIntention") }} + + + {{ t("consul.ui.check") }} + {{ t("consul.ui.matchSource") }} + {{ t("consul.ui.matchDestination") }} + {{ intentionCheckResult ? t("consul.ui.allowed") : t("consul.ui.denied") }} + + {{ t("consul.ui.matchedIntentions", { count: intentionMatches.length }) }} + + + + {{ item.SourceName }} → {{ item.DestinationName }} + + {{ 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") + }} + + + {{ t("consul.ui.details") }} + + + + + + {{ t("consul.ui.compile") }} + + + {{ chain.ServiceName }} · {{ chain.Protocol || t("consul.ui.defaultProtocol") }} · {{ chain.Datacenter || t("consul.ui.configuredDatacenter") }} + + + + {{ t("consul.ui.start") }}{{ row.id }} + + {{ row.type }} + {{ row.next.length ? t("consul.ui.next", { nodes: row.next.join(", ") }) : t("consul.ui.terminalTarget") }} + + + + {{ t("consul.ui.upstreamTargets", { count: Object.keys(chain.Targets).length }) }} + {{ JSON.stringify(chain.Targets, null, 2) }} + + + + + {{ t("consul.ui.generateToken") }}{{ t("consul.ui.establish") }} + + + {{ tokenRevealed ? tokenOnce : "****************" }}{{ t("consul.ui.copyOnce") }}{{ t("consul.ui.hide") }} + + + + + {{ item.Name }} + {{ peeringStateLabel(item.State) }} · {{ item.Partition || t("consul.ui.default") }} + + + + {{ t("consul.ui.editExportedServices") }} + {{ JSON.stringify(exported, null, 2) }} + + + {{ editorMode === "config" ? t("consul.ui.serviceMeshConfigEntry") : editorMode === "intention" ? t("consul.ui.intentions") : t("consul.ui.exportedServices") }}{{ t("consul.ui.cancel") }}{{ t("consul.ui.applyWithCas") }} + + diff --git a/apps/desktop/src/components/consul/ConsulOperator.vue b/apps/desktop/src/components/consul/ConsulOperator.vue new file mode 100644 index 0000000000..67795f19ef --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulOperator.vue @@ -0,0 +1,165 @@ + + + + + + {{ t("consul.ui.operatorReadOnly") }} + + {{ t("consul.ui.snapshot") }} + + + + + {{ kind.replaceAll("_", " ") }} + {{ failures[kind] }} + + + {{ field.name }} + {{ field.value }} + + + + + {{ writeError }} + + + {{ t("consul.ui.snapshotRestore") }} + + api.consulSnapshotRestore(props.connectionId, { snapshotBase64: snapshot.data, targetDatacenter: snapshot.datacenter, confirmation: snapshot.confirmation }))" + >{{ t("consul.ui.restoreSnapshot") }} + + + + {{ t("consul.ui.autopilotUpdate") }} + {{ t("consul.ui.cleanupDeadServers") }} api.consulAutopilotUpdate(props.connectionId, { CleanupDeadServers: autopilot.cleanupDeadServers }, autopilot.confirmation))">{{ + t("consul.ui.updateAutopilot") + }} + + + {{ t("consul.ui.raftOperations") }} + + + + api.consulRaftTransfer(props.connectionId, { serverId: raft.serverId || null, address: null, confirmation: raft.confirmation }))">{{ t("consul.ui.transferLeader") }} api.consulRaftRemove(props.connectionId, { serverId: raft.serverId || null, address: raft.address || null, confirmation: raft.confirmation }))">{{ t("consul.ui.removePeer") }} + + + + + {{ t("consul.ui.gossipKeyring") }} + + {{ t("consul.ui.operation") }} + {{ t("consul.ui.install") }} + {{ t("consul.ui.use") }} + {{ t("consul.ui.remove") }} api.consulKeyringWrite(props.connectionId, keyring))">{{ + t("consul.ui.changeKeyring") + }} + + + + {{ t("consul.ui.licenseUpdate") }} + + api.consulLicenseWrite(props.connectionId, { license: license.value, confirmation: license.confirmation }))" + >{{ t("consul.ui.updateLicense") }} + + + + + diff --git a/apps/desktop/src/components/consul/ConsulOverview.vue b/apps/desktop/src/components/consul/ConsulOverview.vue new file mode 100644 index 0000000000..dd919cb01d --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulOverview.vue @@ -0,0 +1,157 @@ + + + + + + {{ t("consul.ui.overview") }} + + + + + {{ row[0] }} + {{ row[1] }} + + + + + {{ probeLabel(key) }} + + {{ probeStateLabel(probeStates[key]) }} + + + + + + + + {{ source }}{{ error }} + + + + + {{ t("consul.ui.autopilotHealth") }} + + + {{ field.name }} + {{ field.value }} + + + + + {{ t("consul.ui.members") }} + + + + {{ t("consul.ui.node") }} + {{ t("consul.ui.address") }} + {{ t("consul.ui.status") }} + + + + + {{ member.Name }} + {{ member.Addr }}:{{ member.Port }} + {{ member.Status === 1 ? t("consul.ui.alive") : member.Status === 2 ? t("consul.ui.leaving") : t("consul.ui.failed") }} + + + + + + diff --git a/apps/desktop/src/components/consul/ConsulScope.vue b/apps/desktop/src/components/consul/ConsulScope.vue new file mode 100644 index 0000000000..66f19671c0 --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulScope.vue @@ -0,0 +1,221 @@ + + + + + {{ t("consul.ui.datacenter") }}: {{ connectionScope.dc }} · {{ t("consul.ui.adminPartition") }}: {{ connectionScope.partition }} · {{ t("consul.ui.namespace") }}: {{ connectionScope.namespace }} + + + + {{ t("consul.ui.namespaces") }}{{ t("consul.workspace.partitions") }} + + {{ t("consul.ui.create") }} + + + + {{ t("consul.ui.enterpriseCapability", { status: capabilityLabel(status) }) }} + {{ t("consul.ui.loading") }} + {{ error }} + + + + {{ nameOf(item) }} + {{ item.Description || "-" }} + + + + {{ t("consul.ui.noResources") }} + + + + + {{ editorName ? t("consul.ui.editResource", { kind: activeLabel }) : t("consul.ui.createResource", { kind: activeLabel }) }}{{ t("consul.ui.cancel") }}{{ t("consul.ui.save") }} + { + if (!value) closeDelete(); + } + " + >{{ t("consul.ui.deleteResource", { label: pendingDelete }) }} + {{ t("consul.ui.loading") }} + + + + {{ t(`consul.ui.impactResource.${resource}`) }} + {{ count }} + + + {{ t("consul.ui.impactAclFiltered") }} + {{ t("consul.ui.impactUnavailable", { resources: impact.unavailableResources.join(", ") }) }} + {{ t("consul.ui.impactBlocked") }} + + {{ t("consul.ui.cancel") }}{{ t("consul.ui.delete") }} + diff --git a/apps/desktop/src/components/consul/ConsulServices.vue b/apps/desktop/src/components/consul/ConsulServices.vue new file mode 100644 index 0000000000..48afa089fb --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulServices.vue @@ -0,0 +1,941 @@ + + + + + + + + + + {{ browseMode === "service" ? t("consul.ui.catalogServices") : t("consul.ui.catalogNodes") }} + {{ browseMode === "service" ? selected || t("consul.ui.services") : selectedNode || t("consul.ui.nodes") }} + + + {{ + agentTargetStatus === "verifying" ? t("consul.ui.agentTargetVerifying") : t("consul.ui.agentTarget", { target: canAgentWrite && agentTarget ? `${agentTarget.node} (${agentTarget.address})` : t("consul.ui.unknownWritesDisabled") }) + }} + + + + + {{ watchPaused ? t("consul.ui.watchPaused") : t("consul.ui.watchingIndex", { index: watchIndex || "1" }) }} + {{ watchEnabled && !watchPaused ? t("consul.ui.pauseWatch") : t("consul.ui.startWatch") }} + {{ t("consul.ui.register") }} + + + + + + {{ name }}: {{ error }} + + + + + {{ t("consul.ui.registrationHint") }} + + + {{ t("consul.ui.name") }} + + + {{ t("consul.ui.id") }} + + + {{ t("consul.ui.address") }} + + + {{ t("consul.ui.port") }} + + + {{ t("consul.ui.tags") }} + + + + {{ t("consul.ui.cancel") }}{{ t("consul.ui.registerOn", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }} + + + + + + + {{ browseMode === "service" ? t("consul.ui.catalogInstances") : t("consul.ui.nodeServices") }}{{ browseMode === "service" ? instances.length : nodeServiceItems.length }} + + + + + + + + + {{ t("consul.ui.node") }} + {{ t("consul.ui.serviceId") }} + {{ t("consul.ui.address") }} + {{ t("consul.ui.tags") }} + {{ t("consul.ui.metadata") }} + + + + + + + {{ item.Node }} + + {{ item.Datacenter }} · {{ Object.keys(item.NodeMeta).length }} {{ t("consul.ui.metadata") }} + + + + {{ item.ServiceID || "-" }} + + + {{ item.ServiceAddress || item.Address }}:{{ item.ServicePort }} + + + + {{ tag }} + + - + + + {{ + Object.entries(item.ServiceMeta) + .map(([key, value]) => `${key}=${value}`) + .join(", ") || "-" + }} + + + + + + + {{ t("consul.ui.serviceKind") }} + {{ item.ServiceKind || t("consul.ui.standard") }} + + + {{ t("consul.ui.indexes") }} + {{ item.CreateIndex }} / {{ item.ModifyIndex }} + + + {{ t("consul.ui.serviceWeights") }} + {{ item.ServiceWeights.Passing }} / {{ item.ServiceWeights.Warning }} + + + {{ t("consul.ui.nodeId") }} + {{ item.ID || "-" }} + + + {{ t("consul.ui.nodeMeta") }} + + {{ key }}={{ value }}- + + + + {{ t("consul.ui.serviceMeta") }} + + {{ key }}={{ value }}- + + + + + + + + {{ t("consul.ui.noVisibleServices") }} + + + + + + + {{ nodeServices?.Node.Address || "-" }}{{ nodeServices?.Node.Datacenter || "-" }} + + + {{ t("consul.ui.nodeMeta") }} · + {{ + Object.entries(nodeServices?.Node.NodeMeta || {}) + .map(([key, value]) => `${key}=${value}`) + .join(", ") || "-" + }} + + + + {{ service.Service }}{{ service.ID }}{{ service.Address || nodeServices?.Node.Address }}:{{ service.Port }} + {{ t("consul.ui.serviceMeta") }} · + {{ + Object.entries(service.Meta) + .map(([key, value]) => `${key}=${value}`) + .join(", ") || "-" + }} + + + + + {{ t("consul.ui.serviceWeights") }} + {{ service.Weights.Passing }} / {{ service.Weights.Warning }} + + + {{ t("consul.ui.tags") }} + + {{ tag }}- + + + + {{ t("consul.ui.serviceMeta") }} + + {{ key }}={{ value }}- + + + + {{ t("consul.ui.taggedAddresses") }} + + {{ key }}={{ value.Address }}:{{ value.Port }}- + + + + + + {{ t("consul.ui.noVisibleServices") }} + + + + + + + {{ t("consul.ui.localAgentServices") }}{{ localServiceFilter.trim() ? `${filteredLocalServiceItems.length} / ${localServiceItems.length}` : localServiceItems.length }} + + + + + + + + {{ + agentTargetStatus === "verifying" ? t("consul.ui.agentTargetVerifying") : agentTargetStatus === "writable" ? t("consul.ui.agentWriteReady") : t("consul.ui.agentReadOnly") + }} + + + + {{ t("consul.ui.agentWriteDisabledHint", { target: identity ? `${identity.node} (${identity.address})` : t("consul.ui.unknownWritesDisabled") }) }} + + + + + {{ service.Service }}{{ service.ID }}{{ t("consul.ui.maintenance") }}{{ service.Address || identity?.address }}:{{ service.Port }}{{ tag }} + + + {{ t("consul.ui.enableMaintenance") }}{{ t("consul.ui.disableMaintenance") }}{{ t("consul.ui.deregister") }} + + + + + + {{ t("consul.ui.kind") }} + {{ selectedLocalService.Kind || t("consul.ui.standard") }} + + + {{ t("consul.ui.datacenter") }} + {{ selectedLocalService.Datacenter || "-" }} + + + {{ t("consul.ui.enableTagOverride") }} + {{ selectedLocalService.EnableTagOverride }} + + + {{ t("consul.ui.serviceWeights") }} + {{ selectedLocalService.Weights.Passing }} / {{ selectedLocalService.Weights.Warning }} + + + {{ t("consul.ui.metadata") }} + + {{ + Object.entries(selectedLocalService.Meta) + .map(([key, value]) => `${key}=${value}`) + .join(", ") || "-" + }} + + + + + + {{ t("consul.ui.loading") }} + + {{ t("consul.ui.noResources") }}{{ t("consul.ui.refresh") }} + + {{ t("consul.ui.noMatchingServices") }} + + + + diff --git a/apps/desktop/src/components/consul/ConsulSessions.vue b/apps/desktop/src/components/consul/ConsulSessions.vue new file mode 100644 index 0000000000..c621c0f161 --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulSessions.vue @@ -0,0 +1,648 @@ + + + + + + + {{ t("consul.ui.sessionsAndLocks") }} + {{ t("consul.ui.sessionDescription") }} + + + + + + {{ t("consul.ui.lock") }} + {{ t("consul.ui.create") }} + + + + {{ error }} + + + {{ t("consul.ui.createSession") }} + + + {{ t("consul.ui.name") }} + + + {{ t("consul.ui.nodeOptional") }} + + + {{ t("consul.ui.ttlOptional") }} + {{ t("consul.ui.ttlRangeHint") }} + + + {{ t("consul.ui.lockDelay") }} + {{ t("consul.ui.lockDelayHint") }} + + + {{ t("consul.ui.behavior") }} + {{ t("consul.ui.releaseLocks") }} + {{ t("consul.ui.deleteKeys") }} + + {{ t("consul.ui.behaviorHint") }} + + + {{ t("consul.ui.nodeChecks") }} + + + {{ t("consul.ui.serviceChecks") }} + {{ t("consul.ui.serviceChecksHint") }} + + + + {{ t("consul.ui.cancel") }}{{ t("consul.ui.create") }} + + + + + + {{ t("consul.ui.lockManagement") }}{{ t("consul.ui.lockHint") }} + + + + {{ t("consul.ui.key") }} + + {{ t("consul.ui.inspectKey") }} + + + + {{ t("consul.ui.selectSession") }} + {{ t("consul.ui.selectSession") }} + {{ session.Name || t("consul.ui.unnamedSession") }} · {{ session.ID }} + + + + {{ t("consul.ui.value") }} + + + {{ lockInspection.found ? t("consul.ui.keyExists") : t("consul.ui.keyNotFound") }} + {{ t("consul.ui.modifyIndexValue", { index: lockInspection.modifyIndex }) }} + {{ t("consul.ui.keyHeldBy", { session: lockInspection.owner }) }} + {{ t("consul.ui.keyUnlocked") }} + + + + {{ t("consul.ui.close") }}{{ t("consul.ui.releaseMatchingSession") }}{{ t("consul.ui.acquire") }} + + + + + + + + {{ t("consul.ui.allSessionTypes") }} + {{ t("consul.ui.ttlSessions") }} + {{ t("consul.ui.persistentSessions") }} + + + {{ t("consul.ui.allBehaviors") }} + {{ t("consul.ui.behaviorRelease") }} + {{ t("consul.ui.behaviorDelete") }} + + {{ t("consul.ui.clearFilters") }} + + + {{ t("consul.ui.sessionResultCount", { filtered: filteredSessions.length, total: sessions.length }) }} + {{ t("consul.ui.aclFilteredSessions") }} + + + + + + + {{ t("consul.ui.sessions") }}{{ filteredSessions.length }} + + + + + + + + + + {{ session.Name || t("consul.ui.unnamedSession") }} + {{ session.ID }} + + + + {{ session.Node || "—" }} + {{ session.Namespace || t("consul.ui.default") }} / {{ session.Partition || t("consul.ui.default") }} + + + {{ isTtlSession(session) ? session.TTL : t("consul.ui.noTtl") }}{{ behaviorLabel(session.Behavior) }} + + + {{ t("consul.ui.heldKeys") }} + + {{ autoRenewId === session.ID ? t("consul.ui.stop") : t("consul.ui.autoRenew") }} + {{ t("consul.ui.noRenewRequired") }} + + + + + + + + {{ t("consul.ui.node") }} + {{ session.Node || "—" }} + + + {{ t("consul.ui.lockDelay") }} + {{ formatLockDelay(session.LockDelay) }} + + + TTL + {{ session.TTL || t("consul.ui.noTtl") }} + + + {{ t("consul.ui.behavior") }} + {{ behaviorLabel(session.Behavior) }} + + + {{ t("consul.ui.createIndex") }} + {{ session.CreateIndex }} + + + {{ t("consul.ui.modifyIndexLabel") }} + {{ session.ModifyIndex }} + + + {{ t("consul.ui.checks") }} + + {{ check }}— + + + + + + + + {{ t("consul.ui.heldKeys") }}{{ heldKeys[session.ID].items.length }} + + {{ t("consul.ui.heldKeysIncomplete") }}{{ t("consul.ui.complete") }} + + {{ t("consul.ui.loading") }} + + {{ item.key }}{{ t("consul.ui.lockIndex", { index: item.lockIndex }) }} + + {{ t("consul.ui.noHeldKeys") }} + + + + + {{ hasFilters ? t("consul.ui.noMatchingSessions") : t("consul.ui.noVisibleSessions") }} + {{ t("consul.ui.loading") }} + + + diff --git a/apps/desktop/src/components/consul/ConsulTools.vue b/apps/desktop/src/components/consul/ConsulTools.vue new file mode 100644 index 0000000000..fc69c51777 --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulTools.vue @@ -0,0 +1,370 @@ + + + + + + {{ t("consul.ui.tools") }} + + + + + + + {{ t("consul.ui.preparedQueries") }} + {{ t("consul.ui.events") }} + {{ t("consul.ui.coordinates") }} + + + + {{ t("consul.ui.preparedQueriesDescription") }} + {{ t("consul.ui.capability", { status: capabilityLabel(capabilities.preparedQueries) }) }} + {{ queryError }} + + + + {{ editingQueryId ? t("consul.ui.update") : t("consul.ui.create") }} + + + + + + {{ query.Name || query.ID }} + {{ t("consul.ui.targetServiceValue", { service: query.Service.Service }) }} · {{ t("consul.ui.id") }} {{ query.ID }} + + + + + + + {{ t("consul.ui.noPreparedQueries") }} + {{ t("consul.ui.loading") }} + + + + {{ t("consul.ui.queryExecutionResult", { count: queryResult.Nodes.length, datacenter: queryResult.Datacenter || "-" }) }}DNS TTL {{ queryResult.DNS.TTL }} + + + {{ item.Node.Node }}{{ item.Service.ID }}{{ queryEndpoint(item) }} + + {{ t("consul.ui.noQueryNodes") }} + + + {{ t("consul.ui.queryExplanation") }} + {{ JSON.stringify(queryDetails, null, 2) }} + + + + + {{ t("consul.ui.eventsDescription") }} + {{ t("consul.ui.capability", { status: capabilityLabel(capabilities.events) }) }} + {{ eventError }} + + + + + {{ t("consul.ui.fire") }} + + + + + + + {{ t("consul.ui.eventPayloadBytes", { count: eventPayloadBytes }) }} + + + + + {{ event.Name }}LTime {{ event.LTime }} + + + {{ t("consul.ui.node") }}: {{ event.NodeFilter }}{{ t("consul.ui.service") }}: {{ event.ServiceFilter }}{{ t("consul.ui.tags") }}: {{ event.TagFilter }} + + {{ decodeEventPayload(event.Payload) }} + + {{ t("consul.ui.noEvents") }} + {{ t("consul.ui.loading") }} + + + + + {{ t("consul.ui.coordinatesDescription") }} + {{ t("consul.ui.capability", { status: capabilityLabel(capabilities.coordinates) }) }} + {{ coordinateError }} + + + {{ t("consul.ui.node") }}{{ t("consul.ui.segment") }}{{ t("consul.ui.error") }}{{ t("consul.ui.adjustment") }}{{ t("consul.ui.height") }} + + + {{ item.Node }}{{ item.Segment || t("consul.ui.default") }}{{ item.Coord.Error }}{{ item.Coord.Adjustment }}{{ item.Coord.Height }} + + {{ t("consul.ui.noCoordinates") }} + {{ t("consul.ui.loading") }} + + + + + diff --git a/apps/desktop/src/components/consul/ConsulWorkspace.vue b/apps/desktop/src/components/consul/ConsulWorkspace.vue new file mode 100644 index 0000000000..f849b20a3d --- /dev/null +++ b/apps/desktop/src/components/consul/ConsulWorkspace.vue @@ -0,0 +1,236 @@ + + + + + + + + {{ t("consul.workspace.kv") }} + {{ t("consul.workspace.services") }} + {{ t("consul.workspace.health") }} + {{ t("consul.workspace.sessions") }} + {{ t("consul.workspace.acl") }} + {{ t("consul.workspace.scope") }} + {{ t("consul.workspace.mesh") }} + {{ t("consul.workspace.tools") }} + {{ t("consul.workspace.operator") }} + + + + {{ scopeLabel }} + {{ scopeLabel }} + + + + + + + + + + + + + + + + + + {{ t("consul.ui.scope") }} + + + {{ t("consul.ui.datacenter") }} + {{ datacenter }} + + + + + {{ t("consul.ui.adminPartition") }} + + + {{ t("consul.ui.namespace") }} + + + {{ scopeError }} + + {{ t("consul.ui.cancel") }}{{ t("consul.ui.applyScope") }} + + + diff --git a/apps/desktop/src/components/consul/__tests__/ConsulAclInteraction.spec.ts b/apps/desktop/src/components/consul/__tests__/ConsulAclInteraction.spec.ts new file mode 100644 index 0000000000..119a8c2131 --- /dev/null +++ b/apps/desktop/src/components/consul/__tests__/ConsulAclInteraction.spec.ts @@ -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> }; +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((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>) { + 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"); + }); +}); diff --git a/apps/desktop/src/components/consul/__tests__/ConsulScopeInteraction.spec.ts b/apps/desktop/src/components/consul/__tests__/ConsulScopeInteraction.spec.ts new file mode 100644 index 0000000000..9c1f79e4fa --- /dev/null +++ b/apps/desktop/src/components/consul/__tests__/ConsulScopeInteraction.spec.ts @@ -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> }) => 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>) { + 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"); + }); +}); diff --git a/apps/desktop/src/components/consul/__tests__/ConsulServicesInteraction.spec.ts b/apps/desktop/src/components/consul/__tests__/ConsulServicesInteraction.spec.ts new file mode 100644 index 0000000000..9ea28e6589 --- /dev/null +++ b/apps/desktop/src/components/consul/__tests__/ConsulServicesInteraction.spec.ts @@ -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> = {}; + return { + connectionConfig: { base_url: "http://127.0.0.1:8500", external_config: {} } as Record, + 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"); + }); +}); diff --git a/apps/desktop/src/components/consul/__tests__/ConsulWatchViews.spec.ts b/apps/desktop/src/components/consul/__tests__/ConsulWatchViews.spec.ts new file mode 100644 index 0000000000..14aed97166 --- /dev/null +++ b/apps/desktop/src/components/consul/__tests__/ConsulWatchViews.spec.ts @@ -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(" { + 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(" { + expect(tools).toContain('const activeTab = ref("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(" { + 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(""); + expect(keyBrowser).not.toContain('v-model="watchPrefix" class="h-7 w-52 text-xs"'); + expect(kvBrowser).toContain(''); + 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(''); + 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(' { + 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("kv");'); + expect(workspace).not.toContain(' { + 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";'); + }); +}); diff --git a/apps/desktop/src/components/consul/__tests__/ConsulWorkspaceInteraction.spec.ts b/apps/desktop/src/components/consul/__tests__/ConsulWorkspaceInteraction.spec.ts new file mode 100644 index 0000000000..70cf74d9c3 --- /dev/null +++ b/apps/desktop/src/components/consul/__tests__/ConsulWorkspaceInteraction.spec.ts @@ -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)); + }); +}); diff --git a/apps/desktop/src/components/diff/DataCompareDialog.vue b/apps/desktop/src/components/diff/DataCompareDialog.vue index 9c8009c3fb..990f0a7663 100644 --- a/apps/desktop/src/components/diff/DataCompareDialog.vue +++ b/apps/desktop/src/components/diff/DataCompareDialog.vue @@ -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(() => { diff --git a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue index 7779d3adb9..db6430f477 100644 --- a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue +++ b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue @@ -58,7 +58,7 @@ const targetSchemas = ref([]); const sourceDbVersion = ref(null); const targetDbVersion = ref(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)); diff --git a/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue b/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue index 321825c5d1..97889663fb 100644 --- a/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue +++ b/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue @@ -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" diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index 15645ccb25..f2a919f4e0 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -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; diff --git a/apps/desktop/src/components/icons/DatabaseIcon.vue b/apps/desktop/src/components/icons/DatabaseIcon.vue index 6810d1987f..d5f56ca62a 100644 --- a/apps/desktop/src/components/icons/DatabaseIcon.vue +++ b/apps/desktop/src/components/icons/DatabaseIcon.vue @@ -99,6 +99,7 @@ const assetIcons: Record = { rocketmq: "rocketmq", rabbitmq: "rabbitmq", nacos: "nacos.png", + consul: "consul", iris: "iris", influxdb: "influxdb", victoriametrics: "victoriametrics.png", diff --git a/apps/desktop/src/components/kv/KvKeyBrowser.vue b/apps/desktop/src/components/kv/KvKeyBrowser.vue index e96164d56a..9eecbccd45 100644 --- a/apps/desktop/src/components/kv/KvKeyBrowser.vue +++ b/apps/desktop/src/components/kv/KvKeyBrowser.vue @@ -1,15 +1,16 @@
{{ t("connection.consulAgentTargetHint") }}
{{ t("connection.consulMeshVisibleHint") }}
{{ t("consul.ui.tokenReferences", { tokens: references.tokenAccessorIds.length, roles: references.roleIds.length, bindingRules: references.bindingRuleIds.length }) }}
{{ t("consul.ui.impactBlocked") }}
{{ t("consul.ui.checkRegistrationHint", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }}
{{ t("consul.tools.exportLimitHint") }}
{{ JSON.stringify(chain.Targets, null, 2) }}
{{ JSON.stringify(exported, null, 2) }}
{{ failures[kind] }}
{{ t("consul.ui.impactAclFiltered") }}
{{ t("consul.ui.impactUnavailable", { resources: impact.unavailableResources.join(", ") }) }}
{{ t("consul.ui.registrationHint") }}
{{ key }}={{ value }}
{{ service.ID }}
{{ key }}={{ value.Address }}:{{ value.Port }}
{{ t("consul.ui.sessionDescription") }}
{{ t("consul.ui.ttlRangeHint") }}
{{ t("consul.ui.lockDelayHint") }}
{{ t("consul.ui.behaviorHint") }}
{{ t("consul.ui.serviceChecksHint") }}
{{ t("consul.ui.preparedQueriesDescription") }}
{{ JSON.stringify(queryDetails, null, 2) }}
{{ t("consul.ui.eventsDescription") }}
{{ t("consul.ui.coordinatesDescription") }}
{{ scopeError }}