feat(consul): add Consul full-stack workbench
This commit is contained in:
148 files changed
+24032
-378
No files matched your search
@@ -1546,7 +1546,7 @@ async function openConnectionQuery(connectionId: string) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper") {
|
||||
if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper" || initialTarget.kind === "consul") {
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
queryStore.createTab(connectionId, "", `${connection.name}:keys`, initialTarget.kind);
|
||||
@@ -1968,6 +1968,8 @@ async function handleQuickOpenSelect(item: any) {
|
||||
await connectionStore.loadEtcdRoot(item.connectionId);
|
||||
} else if (config?.db_type === "zookeeper") {
|
||||
await connectionStore.loadZooKeeperRoot(item.connectionId);
|
||||
} else if (config?.db_type === "consul") {
|
||||
await connectionStore.loadConsulRoot(item.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(item.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
@@ -1993,6 +1995,8 @@ async function handleQuickOpenSelect(item: any) {
|
||||
await connectionStore.loadEtcdRoot(item.connectionId);
|
||||
} else if (config?.db_type === "zookeeper") {
|
||||
await connectionStore.loadZooKeeperRoot(item.connectionId);
|
||||
} else if (config?.db_type === "consul") {
|
||||
await connectionStore.loadConsulRoot(item.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(item.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
|
||||
@@ -53,6 +53,7 @@ import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriv
|
||||
import { JDBCX_DEFAULT_URL, JDBCX_DRIVER_PROFILE, JDBCX_JDBC_DRIVER_CLASS, ensureJdbcxRuntimeDrivers, isJdbcxRuntimeBundle, isJdbcxRuntimePath, jdbcxHighPrivilegeExtensionsEnabled, setJdbcxHighPrivilegeExtensionsEnabled } from "@/lib/database/jdbcxBuiltinDriver";
|
||||
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection";
|
||||
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout";
|
||||
import { consulAgentAddressesMatch } from "@/lib/consul/agentTarget";
|
||||
import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints";
|
||||
import { preventDialogDocumentSelectAll } from "@/lib/connection/dialogTextSelection";
|
||||
import { postgresTlsModeForForm } from "@/lib/connection/postgresTlsMode";
|
||||
@@ -809,6 +810,22 @@ const nacosTlsSkipVerify = ref(false);
|
||||
const nacosMetricsMode = ref<NacosMetricsMode>("auto");
|
||||
const nacosMetricsUrl = ref("");
|
||||
const nacosPageSize = ref(20);
|
||||
type ConsulConsistency = "default" | "stale" | "consistent";
|
||||
const consulServerAddr = ref("http://127.0.0.1:8500");
|
||||
const consulDatacenter = ref("");
|
||||
const consulNamespace = ref("");
|
||||
const consulPartition = ref("");
|
||||
const consulConsistency = ref<ConsulConsistency>("default");
|
||||
const consulTlsSkipVerify = ref(false);
|
||||
const consulAgentTargetNode = ref("");
|
||||
const consulAgentTargetAddress = ref("");
|
||||
const consulMeshVisible = ref(false);
|
||||
const consulOperatorVisible = ref(false);
|
||||
const consulOperatorSnapshotRestoreEnabled = ref(false);
|
||||
const consulOperatorAutopilotWriteEnabled = ref(false);
|
||||
const consulOperatorRaftWriteEnabled = ref(false);
|
||||
const consulOperatorKeyringWriteEnabled = ref(false);
|
||||
const consulOperatorLicenseWriteEnabled = ref(false);
|
||||
|
||||
// --- MQTT-specific form fields ---
|
||||
const mqttHost = ref("127.0.0.1");
|
||||
@@ -1131,6 +1148,7 @@ const driverProfiles: Record<
|
||||
rocketmq: { type: "mq", port: 9876, user: "", label: "Apache RocketMQ", icon: "rocketmq", host: "127.0.0.1" },
|
||||
rabbitmq: { type: "mq", port: 5672, user: "", label: "RabbitMQ", icon: "rabbitmq", host: "127.0.0.1" },
|
||||
nacos: { type: "nacos", port: 8848, user: "nacos", label: "Nacos", icon: "nacos", host: "127.0.0.1" },
|
||||
consul: { type: "consul", port: 8500, user: "", label: "Consul", icon: "consul", host: "127.0.0.1" },
|
||||
mqtt: { type: "mqtt", port: 1883, user: "", label: "MQTT", icon: "mqtt", host: "127.0.0.1" },
|
||||
iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" },
|
||||
influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" },
|
||||
@@ -1351,6 +1369,31 @@ function hydrateNacosFields(value: unknown) {
|
||||
resetNacosFields(value as Partial<NacosAdminConfig>);
|
||||
}
|
||||
|
||||
function resetConsulFields(value?: Record<string, unknown>) {
|
||||
consulServerAddr.value = String(value?.serverAddr || value?.server_addr || "http://127.0.0.1:8500");
|
||||
consulDatacenter.value = String(value?.datacenter || value?.consulDatacenter || value?.consul_datacenter || "");
|
||||
consulNamespace.value = String(value?.namespace || value?.consulNamespace || value?.consul_namespace || "");
|
||||
consulPartition.value = String(value?.partition || value?.consulPartition || value?.consul_partition || "");
|
||||
const consistency = String(value?.consistency || value?.consulConsistency || value?.consul_consistency || "default");
|
||||
consulConsistency.value = (["default", "stale", "consistent"].includes(consistency) ? consistency : "default") as ConsulConsistency;
|
||||
consulTlsSkipVerify.value = Boolean(value?.tlsSkipVerify || value?.tls_skip_verify || value?.consulTlsSkipVerify || value?.consul_tls_skip_verify);
|
||||
const agentTarget = value?.agentTarget || value?.agent_target;
|
||||
const target = agentTarget && typeof agentTarget === "object" ? (agentTarget as Record<string, unknown>) : undefined;
|
||||
consulAgentTargetNode.value = String(target?.node || "");
|
||||
consulAgentTargetAddress.value = String(target?.address || "");
|
||||
consulMeshVisible.value = Boolean(value?.consulMeshVisible);
|
||||
consulOperatorVisible.value = Boolean(value?.consulOperatorVisible);
|
||||
consulOperatorSnapshotRestoreEnabled.value = Boolean(value?.consulOperatorSnapshotRestoreEnabled);
|
||||
consulOperatorAutopilotWriteEnabled.value = Boolean(value?.consulOperatorAutopilotWriteEnabled);
|
||||
consulOperatorRaftWriteEnabled.value = Boolean(value?.consulOperatorRaftWriteEnabled);
|
||||
consulOperatorKeyringWriteEnabled.value = Boolean(value?.consulOperatorKeyringWriteEnabled);
|
||||
consulOperatorLicenseWriteEnabled.value = Boolean(value?.consulOperatorLicenseWriteEnabled);
|
||||
}
|
||||
|
||||
function hydrateConsulFields(value: unknown) {
|
||||
resetConsulFields(value && typeof value === "object" ? (value as Record<string, unknown>) : undefined);
|
||||
}
|
||||
|
||||
function resetMqttFields(config?: Partial<MqttConnectionConfig>) {
|
||||
mqttHost.value = config?.host?.trim() || "127.0.0.1";
|
||||
mqttPort.value = config?.port || 1883;
|
||||
@@ -1685,6 +1728,46 @@ function buildNacosAdminConfig(): NacosAdminConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function buildConsulExternalConfig(): Record<string, unknown> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(consulServerAddr.value.trim());
|
||||
} catch {
|
||||
throw new Error(t("connection.consulAddressInvalid"));
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(t("connection.consulAddressInvalid"));
|
||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error(t("connection.consulAddressInvalid"));
|
||||
const targetNode = consulAgentTargetNode.value.trim();
|
||||
const targetAddress = consulAgentTargetAddress.value.trim();
|
||||
if (!!targetNode !== !!targetAddress) throw new Error(t("connection.consulAgentTargetIncomplete"));
|
||||
if (targetAddress && !consulAgentAddressesMatch(targetAddress, parsed.hostname)) {
|
||||
throw new Error(t("connection.consulAgentTargetAddressMismatch"));
|
||||
}
|
||||
return {
|
||||
serverAddr: parsed.toString().replace(/\/$/, ""),
|
||||
datacenter: consulDatacenter.value.trim() || undefined,
|
||||
namespace: consulNamespace.value.trim() || undefined,
|
||||
partition: consulPartition.value.trim() || undefined,
|
||||
consistency: consulConsistency.value,
|
||||
tlsSkipVerify: consulTlsSkipVerify.value || undefined,
|
||||
agentTarget: targetNode ? { node: targetNode, address: targetAddress } : undefined,
|
||||
consulMeshVisible: consulMeshVisible.value || undefined,
|
||||
consulOperatorVisible: consulOperatorVisible.value || undefined,
|
||||
consulOperatorSnapshotRestoreEnabled: consulOperatorSnapshotRestoreEnabled.value || undefined,
|
||||
consulOperatorAutopilotWriteEnabled: consulOperatorAutopilotWriteEnabled.value || undefined,
|
||||
consulOperatorRaftWriteEnabled: consulOperatorRaftWriteEnabled.value || undefined,
|
||||
consulOperatorKeyringWriteEnabled: consulOperatorKeyringWriteEnabled.value || undefined,
|
||||
consulOperatorLicenseWriteEnabled: consulOperatorLicenseWriteEnabled.value || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function applyConsulServerAddr(config: LegacyConnectionConfig, serverAddr: string) {
|
||||
const parsed = new URL(serverAddr);
|
||||
config.host = parsed.hostname;
|
||||
config.port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8500);
|
||||
config.ssl = parsed.protocol === "https:";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
@@ -2262,6 +2345,14 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
||||
form.value.connection_string = undefined;
|
||||
form.value.url_params = "";
|
||||
}
|
||||
if (profile.type === "consul") {
|
||||
resetConsulFields();
|
||||
form.value.database = undefined;
|
||||
form.value.connection_string = undefined;
|
||||
form.value.username = "";
|
||||
form.value.password = "";
|
||||
form.value.url_params = "";
|
||||
}
|
||||
if (profile.type === "mqtt") {
|
||||
resetMqttFields();
|
||||
form.value.database = undefined;
|
||||
@@ -2384,6 +2475,11 @@ watch(
|
||||
} else {
|
||||
resetNacosFields();
|
||||
}
|
||||
if (config.db_type === "consul") {
|
||||
hydrateConsulFields(config.external_config);
|
||||
} else {
|
||||
resetConsulFields();
|
||||
}
|
||||
if (config.db_type === "mqtt") {
|
||||
hydrateMqttFields(config.external_config);
|
||||
} else {
|
||||
@@ -2640,6 +2736,7 @@ const iconTypeMap: Record<string, string> = {
|
||||
rocketmq: "rocketmq",
|
||||
rabbitmq: "rabbitmq",
|
||||
nacos: "nacos",
|
||||
consul: "consul",
|
||||
mqtt: "mqtt",
|
||||
dm: "dm",
|
||||
h2: "h2",
|
||||
@@ -2745,6 +2842,7 @@ const dbOptions: DbOption[] = [
|
||||
{ value: "rabbitmq", label: "RabbitMQ" },
|
||||
{ value: "mqtt", label: "MQTT" },
|
||||
{ value: "nacos", label: "Nacos" },
|
||||
{ value: "consul", label: "Consul" },
|
||||
{ value: "influxdb", label: "InfluxDB" },
|
||||
{ value: "victoriametrics", label: "VictoriaMetrics" },
|
||||
{ value: "iris", label: "IRIS" },
|
||||
@@ -2805,7 +2903,7 @@ const dbCategoryDefinitions: Array<{
|
||||
{
|
||||
key: "registry_config",
|
||||
titleKey: "connection.databaseCategoryRegistryConfig",
|
||||
optionValues: ["etcd", "zookeeper", "nacos"],
|
||||
optionValues: ["etcd", "zookeeper", "nacos", "consul"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2921,6 +3019,7 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
|
||||
"dameng",
|
||||
"redis",
|
||||
"etcd",
|
||||
"consul",
|
||||
"clickhouse",
|
||||
"elasticsearch",
|
||||
"easysearch",
|
||||
@@ -3279,6 +3378,7 @@ const hasRequiredConnectionTarget = computed(() => {
|
||||
if (form.value.db_type === "zookeeper") return !!(form.value.host || form.value.connection_string || connectionUrlInput.value.trim());
|
||||
if (form.value.db_type === "mqtt") return !!mqttHost.value.trim() && mqttPort.value > 0;
|
||||
if (form.value.db_type === "nacos") return !!nacosServerAddr.value.trim();
|
||||
if (form.value.db_type === "consul") return !!consulServerAddr.value.trim();
|
||||
if (isCloudflareD1Connection(form.value)) return hasCloudflareD1Credentials(form.value);
|
||||
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
|
||||
return !!(form.value.host || (mongoUseUrl.value && form.value.connection_string) || (form.value.db_type === "jdbc" && form.value.connection_string) || connectionUrlInput.value.trim());
|
||||
@@ -3679,6 +3779,20 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
config.database = undefined;
|
||||
config.connection_string = undefined;
|
||||
config.url_params = "";
|
||||
} else if (config.db_type === "consul") {
|
||||
const consulConfig = buildConsulExternalConfig();
|
||||
config.external_config = consulConfig;
|
||||
applyConsulServerAddr(config, String(consulConfig.serverAddr));
|
||||
config.client_cert_path = config.client_cert_path?.trim() || "";
|
||||
config.client_key_path = config.client_key_path?.trim() || "";
|
||||
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
|
||||
throw new Error(t("connection.etcdClientCertPairRequired"));
|
||||
}
|
||||
config.username = "";
|
||||
config.password = config.password.trim();
|
||||
config.database = undefined;
|
||||
config.connection_string = undefined;
|
||||
config.url_params = "";
|
||||
} else if (config.db_type === "mqtt") {
|
||||
const mqttConfig = buildMqttExternalConfig();
|
||||
config.external_config = mqttConfig;
|
||||
@@ -3813,12 +3927,12 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
|
||||
throw new Error(t("connection.etcdClientCertPairRequired"));
|
||||
}
|
||||
} else {
|
||||
} else if (form.value.db_type !== "consul") {
|
||||
config.etcd_endpoints = undefined;
|
||||
config.client_cert_path = undefined;
|
||||
config.client_key_path = undefined;
|
||||
}
|
||||
if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd" && config.db_type !== "starrocks" && config.db_type !== "mongodb" && config.db_type !== "victoriametrics") {
|
||||
if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd" && config.db_type !== "consul" && config.db_type !== "starrocks" && config.db_type !== "mongodb" && config.db_type !== "victoriametrics") {
|
||||
config.ca_cert_path = undefined;
|
||||
} else {
|
||||
config.ca_cert_path = config.ca_cert_path?.trim() || "";
|
||||
@@ -5442,7 +5556,7 @@ function openExternalUrl(url: string) {
|
||||
|
||||
<TabsContent value="connection" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6" :class="{ 'connection-form-body--nacos': form.db_type === 'nacos' }">
|
||||
<div v-if="!isJdbcConnection && form.db_type !== 'nacos'" class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="!isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="connectionUrlInput" class="flex-1" :placeholder="connectionUrlPlaceholder" @keydown.enter.prevent="applyConnectionUrl" />
|
||||
@@ -6174,6 +6288,80 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Consul KV: HTTP endpoint, ACL token and scope -->
|
||||
<template v-else-if="form.db_type === 'consul'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulAddress") }}</Label>
|
||||
<Input v-model="consulServerAddr" class="col-span-3" placeholder="http://127.0.0.1:8500" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulToken") }}</Label>
|
||||
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.consulTokenPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulDatacenter") }}</Label>
|
||||
<Input v-model="consulDatacenter" class="col-span-3" placeholder="dc1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulNamespace") }}</Label>
|
||||
<Input v-model="consulNamespace" class="col-span-3" placeholder="default" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulPartition") }}</Label>
|
||||
<Input v-model="consulPartition" class="col-span-3" placeholder="default" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.consulAgentTargetNode") }}</Label>
|
||||
<Input v-model="consulAgentTargetNode" class="col-span-3" placeholder="consul-client-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.consulAgentTargetAddress") }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<Input v-model="consulAgentTargetAddress" placeholder="127.0.0.1" />
|
||||
<p class="text-xs text-muted-foreground">{{ t("connection.consulAgentTargetHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulConsistency") }}</Label>
|
||||
<Select v-model="consulConsistency">
|
||||
<SelectTrigger class="col-span-3 h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{{ t("connection.consulConsistencyDefault") }}</SelectItem>
|
||||
<SelectItem value="stale">{{ t("connection.consulConsistencyStale") }}</SelectItem>
|
||||
<SelectItem value="consistent">{{ t("connection.consulConsistencyConsistent") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.consulTlsSkipVerify") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<Switch v-model="consulTlsSkipVerify" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.consulTlsSkipVerifyHint") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.consulMeshFeatures") }}</Label>
|
||||
<div class="col-span-3 flex items-start justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium">{{ t("connection.consulMeshVisible") }}</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("connection.consulMeshVisibleHint") }}</p>
|
||||
</div>
|
||||
<Switch v-model="consulMeshVisible" class="mt-0.5 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.consulOperatorWrites") }}</Label>
|
||||
<div class="col-span-3 grid gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs">
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorVisible" type="checkbox" />{{ t("connection.consulOperatorVisible") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorSnapshotRestoreEnabled" type="checkbox" />{{ t("connection.consulOperatorSnapshotRestore") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorAutopilotWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorAutopilot") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorRaftWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorRaft") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorKeyringWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorKeyring") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="consulOperatorLicenseWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorLicense") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- etcd: endpoints, user, password, TLS -->
|
||||
<template v-else-if="form.db_type === 'etcd'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
@@ -6997,7 +7185,7 @@ function openExternalUrl(url: string) {
|
||||
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2 pb-6">
|
||||
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions" class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions && form.db_type !== 'consul'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">SSL/TLS</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="tlsEnabled" class="mr-0" />
|
||||
@@ -7050,7 +7238,7 @@ function openExternalUrl(url: string) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="form.db_type === 'etcd'">
|
||||
<template v-if="form.db_type === 'etcd' || form.db_type === 'consul'">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallPaddedClass">
|
||||
<span class="inline-flex items-center justify-end gap-1">
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { Clipboard, Eye, EyeOff, Loader2, Pencil, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulAclItem, ConsulAclKind, ConsulAclReferences, ConsulAclWrite, ConsulCapabilities } from "@/types/consul";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const active = ref<ConsulAclKind>("token");
|
||||
const items = ref<Record<string, unknown>[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const editorOpen = ref(false);
|
||||
const editorId = ref<string | null>(null);
|
||||
const editorJson = ref("{}");
|
||||
const editorOriginalJson = ref("");
|
||||
const savePreviewOpen = ref(false);
|
||||
const references = ref<ConsulAclReferences | null>(null);
|
||||
const editReferences = ref<ConsulAclReferences | null>(null);
|
||||
const pendingDelete = ref<{ id: string; label: string; kind: ConsulAclKind } | null>(null);
|
||||
const secretOnce = ref("");
|
||||
const secretRevealed = ref(false);
|
||||
const operationBusy = ref(false);
|
||||
let loadSequence = 0;
|
||||
|
||||
const tabs = computed<{ value: ConsulAclKind; label: string }[]>(() => [
|
||||
{ value: "token", label: t("consul.ui.tokens") },
|
||||
{ value: "policy", label: t("consul.ui.policies") },
|
||||
{ value: "role", label: t("consul.ui.roles") },
|
||||
{ value: "authMethod", label: t("consul.ui.authMethods") },
|
||||
{ value: "bindingRule", label: t("consul.ui.bindingRules") },
|
||||
{ value: "templatedPolicy", label: t("consul.ui.templatedPolicies") },
|
||||
]);
|
||||
const activeLabel = computed(() => tabs.value.find((tab) => tab.value === active.value)?.label || active.value);
|
||||
function capabilityLabel(status?: string) {
|
||||
if (status === "supported") return t("consul.ui.capabilitySupported");
|
||||
if (status === "unsupported") return t("consul.ui.capabilityUnsupported");
|
||||
if (status === "disabled") return t("consul.ui.capabilityDisabled");
|
||||
if (status === "forbidden") return t("consul.ui.capabilityForbidden");
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const capability = computed(() => (active.value === "authMethod" ? props.capabilities?.authMethods : active.value === "bindingRule" ? props.capabilities?.bindingRules : active.value === "templatedPolicy" ? props.capabilities?.templatedPolicies : props.capabilities?.acl));
|
||||
const canWrite = computed(() => !readOnly.value && capability.value === "supported" && active.value !== "templatedPolicy");
|
||||
|
||||
function itemId(item: Record<string, unknown>): string {
|
||||
return String(item.AccessorID || item.ID || item.Name || item.TemplateName || "");
|
||||
}
|
||||
function itemLabel(item: Record<string, unknown>): string {
|
||||
return String(item.Name || item.Description || item.AccessorID || item.ID || item.TemplateName || "-");
|
||||
}
|
||||
function clearSecret() {
|
||||
secretOnce.value = "";
|
||||
secretRevealed.value = false;
|
||||
}
|
||||
function scrubSecret(item: Record<string, unknown>) {
|
||||
if (typeof item.SecretID === "string" && item.SecretID) {
|
||||
secretOnce.value = item.SecretID;
|
||||
secretRevealed.value = false;
|
||||
delete item.SecretID;
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
const sequence = ++loadSequence;
|
||||
const connectionId = props.connectionId;
|
||||
const kind = active.value;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
items.value = [];
|
||||
try {
|
||||
const result = await api.consulAclList(connectionId, kind);
|
||||
if (sequence !== loadSequence || connectionId !== props.connectionId || kind !== active.value) return;
|
||||
items.value = (result.items as Record<string, unknown>[]).map((item) => {
|
||||
const safe = { ...item };
|
||||
delete safe.SecretID;
|
||||
return safe;
|
||||
});
|
||||
} catch (cause) {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && kind === active.value) error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && kind === active.value) loading.value = false;
|
||||
}
|
||||
}
|
||||
function openCreate() {
|
||||
editorId.value = null;
|
||||
editorOriginalJson.value = "";
|
||||
editorJson.value = JSON.stringify(defaultItem(active.value), null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
async function openEdit(item: Record<string, unknown>) {
|
||||
const id = itemId(item);
|
||||
editorId.value = id;
|
||||
if (active.value === "templatedPolicy") {
|
||||
editorJson.value = JSON.stringify(item, null, 2);
|
||||
editorOriginalJson.value = editorJson.value;
|
||||
editReferences.value = null;
|
||||
editorOpen.value = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [result, impact]: [ConsulAclItem, ConsulAclReferences | null] = await Promise.all([
|
||||
api.consulAclGet(props.connectionId, active.value, id),
|
||||
active.value === "policy" || active.value === "role" || active.value === "authMethod" ? api.consulAclReferences(props.connectionId, active.value, id) : Promise.resolve(null),
|
||||
]);
|
||||
editReferences.value = impact;
|
||||
const value = { ...(result.item as Record<string, unknown>) };
|
||||
delete value.SecretID;
|
||||
editorJson.value = JSON.stringify(value, null, 2);
|
||||
editorOriginalJson.value = editorJson.value;
|
||||
editorOpen.value = true;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
error.value = "";
|
||||
try {
|
||||
JSON.parse(editorJson.value);
|
||||
savePreviewOpen.value = true;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function confirmSave() {
|
||||
error.value = "";
|
||||
try {
|
||||
const item = JSON.parse(editorJson.value) as Record<string, unknown>;
|
||||
delete item.SecretID;
|
||||
const value = { kind: active.value, item } as ConsulAclWrite;
|
||||
const result = await api.consulAclApply(props.connectionId, editorId.value, value);
|
||||
scrubSecret(result.item as Record<string, unknown>);
|
||||
savePreviewOpen.value = false;
|
||||
editorOpen.value = false;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function runOperation(action: () => Promise<void>) {
|
||||
if (operationBusy.value) return;
|
||||
operationBusy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await action();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
operationBusy.value = false;
|
||||
}
|
||||
}
|
||||
async function inspectDelete(item: Record<string, unknown>) {
|
||||
const id = itemId(item);
|
||||
const label = itemLabel(item);
|
||||
const kind = active.value;
|
||||
const connectionId = props.connectionId;
|
||||
await runOperation(async () => {
|
||||
references.value = await api.consulAclReferences(connectionId, kind, id);
|
||||
pendingDelete.value = { id, label, kind };
|
||||
});
|
||||
}
|
||||
async function cloneToken(item: Record<string, unknown>) {
|
||||
const description = window.prompt(t("consul.ui.cloneTokenDescription"), String(item.Description || ""));
|
||||
if (description === null) return;
|
||||
await runOperation(async () => {
|
||||
const result = await api.consulAclTokenClone(props.connectionId, itemId(item), description);
|
||||
scrubSecret(result as Record<string, unknown>);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function remove() {
|
||||
const pending = pendingDelete.value;
|
||||
if (!pending || !references.value?.complete) return;
|
||||
await runOperation(async () => {
|
||||
await api.consulAclDelete(props.connectionId, pending.kind, pending.id);
|
||||
pendingDelete.value = null;
|
||||
references.value = null;
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function copySecret() {
|
||||
if (!secretOnce.value) return;
|
||||
await runOperation(async () => {
|
||||
await navigator.clipboard.writeText(secretOnce.value);
|
||||
clearSecret();
|
||||
});
|
||||
}
|
||||
function closeDelete() {
|
||||
pendingDelete.value = null;
|
||||
references.value = null;
|
||||
}
|
||||
function defaultItem(kind: ConsulAclKind): Record<string, unknown> {
|
||||
if (kind === "token") return { Description: "", Local: false, Policies: [], Roles: [] };
|
||||
if (kind === "policy") return { Name: "", Description: "", Rules: "", Datacenters: [] };
|
||||
if (kind === "role") return { Name: "", Description: "", Policies: [] };
|
||||
if (kind === "authMethod") return { Name: "", Type: "jwt", DisplayName: "", Description: "", Config: {} };
|
||||
return { Description: "", AuthMethod: "", Selector: "", BindType: "service", BindName: "" };
|
||||
}
|
||||
watch([active, () => props.connectionId], () => {
|
||||
clearSecret();
|
||||
void load();
|
||||
});
|
||||
onMounted(() => void load());
|
||||
onDeactivated(clearSecret);
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
clearSecret();
|
||||
});
|
||||
defineExpose({
|
||||
refresh: () => {
|
||||
void load();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<Tabs v-model="active" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex h-11 items-center justify-between border-b px-3">
|
||||
<TabsList class="h-8 p-0.5"
|
||||
><TabsTrigger v-for="tab in tabs" :key="tab.value" :value="tab.value" class="h-7 px-2 text-xs">{{ tab.label }}</TabsTrigger></TabsList
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="loading || operationBusy" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button
|
||||
><Button v-if="canWrite" size="sm" class="h-7 gap-1 text-xs" :disabled="operationBusy" @click="openCreate"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.create") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent v-for="tab in tabs" :key="tab.value" :value="tab.value" class="m-0 min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="capability !== 'supported'" class="border px-3 py-2 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capability) }) }}</div>
|
||||
<div v-else-if="loading" class="flex h-28 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<div v-else-if="error" class="border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ error }}</div>
|
||||
<div v-else class="divide-y border">
|
||||
<div v-for="item in items" :key="itemId(item)" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ itemLabel(item) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ itemId(item) }}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="operationBusy" :title="t('consul.ui.viewOrEdit')" @click="openEdit(item)"><component :is="canWrite ? Pencil : Eye" class="h-3.5 w-3.5" /></Button
|
||||
><Button v-if="canWrite && active === 'token'" variant="ghost" size="icon" class="h-7 w-7" :disabled="operationBusy" :title="t('consul.ui.cloneToken')" @click="cloneToken(item)"><Clipboard class="h-3.5 w-3.5" /></Button
|
||||
><Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" :disabled="operationBusy" :title="t('consul.ui.deleteAndInspectReferences')" @click="inspectDelete(item)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="!items.length" class="p-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noResources") }}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div v-if="secretOnce" class="m-3 flex items-center gap-2 border border-amber-300 bg-amber-50 p-3 text-sm dark:bg-amber-950/30">
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ secretRevealed ? secretOnce : "****************" }}</span
|
||||
><Button variant="ghost" size="icon" class="h-7 w-7" :title="secretRevealed ? t('consul.ui.hide') : t('consul.ui.reveal')" @click="secretRevealed = !secretRevealed"><EyeOff v-if="secretRevealed" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button
|
||||
><Button size="sm" class="h-7 gap-1" :disabled="operationBusy" @click="copySecret"><Clipboard class="h-3.5 w-3.5" />{{ t("consul.ui.copyOnce") }}</Button
|
||||
><Button variant="ghost" size="sm" @click="clearSecret">{{ t("consul.ui.hide") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog v-model:open="editorOpen"
|
||||
><DialogContent class="max-w-2xl"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ editorId ? t("consul.ui.editResource", { kind: activeLabel }) : t("consul.ui.createResource", { kind: activeLabel }) }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div v-if="editReferences" class="border bg-muted/20 px-3 py-2 text-xs">
|
||||
{{ t("consul.ui.knownImpact", { tokens: editReferences.tokenAccessorIds.length, roles: editReferences.roleIds.length, bindingRules: editReferences.bindingRuleIds.length }) }}<span v-if="!editReferences.complete" class="ml-2 text-destructive">{{ t("consul.ui.incomplete") }}</span>
|
||||
</div>
|
||||
<textarea v-model="editorJson" class="h-80 w-full resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none" spellcheck="false" :readonly="active === 'templatedPolicy'" /><DialogFooter
|
||||
><Button variant="outline" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button v-if="canWrite" @click="save">{{ t("consul.ui.save") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
<NacosConfigDiffDialog v-model:open="savePreviewOpen" :title="t('consul.ui.reviewAclChanges')" :before="editorOriginalJson" :after="editorJson" :confirm-label="t('consul.ui.apply')" @confirm="confirmSave" />
|
||||
<Dialog
|
||||
:open="Boolean(pendingDelete)"
|
||||
@update:open="
|
||||
(value) => {
|
||||
if (!value) closeDelete();
|
||||
}
|
||||
"
|
||||
><DialogContent
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("consul.ui.deleteResource", { label: pendingDelete?.label }) }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div v-if="references" class="space-y-2 text-sm">
|
||||
<p>{{ t("consul.ui.tokenReferences", { tokens: references.tokenAccessorIds.length, roles: references.roleIds.length, bindingRules: references.bindingRuleIds.length }) }}</p>
|
||||
<p v-if="!references.complete" class="text-destructive">{{ t("consul.ui.impactBlocked") }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="operationBusy" @click="closeDelete">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="operationBusy || !references?.complete" @click="remove">{{ t("consul.ui.delete") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,666 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { CircleCheck, CircleX, Loader2, Pause, Plus, Radio, RefreshCcw, Search, Trash2, TriangleAlert, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import ConsulListPagination from "@/components/consul/ConsulListPagination.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { nextWatchIndex } from "@/lib/consul/watchState";
|
||||
import { consulAgentWriteTargetSafe } from "@/lib/consul/agentTarget";
|
||||
import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/lib/consul/pagination";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulAgentCheckDefinition, ConsulAgentCheckRegistration, ConsulAgentIdentity, ConsulCheckStatus, ConsulDomainWatchResponse, ConsulHealthCheck, ConsulScope, ConsulServiceInstance } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const state = ref("any");
|
||||
const browseMode = ref<"state" | "node" | "service">("state");
|
||||
const browseName = ref("");
|
||||
const browseNameDraft = ref("");
|
||||
const keywordFilter = ref("");
|
||||
const statusFilter = ref("any");
|
||||
const typeFilter = ref("any");
|
||||
const checks = ref<ConsulHealthCheck[]>([]);
|
||||
const selectedCheck = ref<ConsulHealthCheck | null>(null);
|
||||
const serviceInstances = ref<ConsulServiceInstance[]>([]);
|
||||
const localChecks = ref<Record<string, ConsulHealthCheck>>({});
|
||||
const identity = ref<ConsulAgentIdentity | null>(null);
|
||||
const checkPage = ref(1);
|
||||
const localCheckPage = ref(1);
|
||||
const serviceInstancePage = ref(1);
|
||||
const agentTarget = computed(() => {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
const raw = config.agentTarget || config.agent_target;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const target = raw as Record<string, unknown>;
|
||||
const node = String(target.node || "").trim();
|
||||
const address = String(target.address || "").trim();
|
||||
return node && address ? { node, address } : null;
|
||||
});
|
||||
const canAgentWrite = computed(() => consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node));
|
||||
const errors = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const editorOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const form = ref({ id: "", name: "", serviceId: "", type: "ttl", endpoint: "", interval: "10s", timeout: "2s", ttl: "30s", args: "" });
|
||||
let sequence = 0;
|
||||
const watchEnabled = ref(false);
|
||||
const watchPaused = ref(false);
|
||||
const watchIndex = ref<string | null>(null);
|
||||
let watchConnectionId = "";
|
||||
let watchScope: ConsulScope = { datacenter: "", namespace: "", partition: "" };
|
||||
let watchGeneration = 0;
|
||||
let watchOperationId = "";
|
||||
|
||||
function normalizedCheckStatus(check: ConsulHealthCheck): string {
|
||||
return check.Maintenance ? "maintenance" : String(check.Status || "unknown").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function checkType(check: ConsulHealthCheck): string {
|
||||
if (check.Maintenance) return "maintenance";
|
||||
if (check.Definition?.TTL) return "ttl";
|
||||
if (check.Definition?.HTTP) return "http";
|
||||
if (check.Definition?.TCP) return "tcp";
|
||||
if (check.Definition?.GRPC) return "grpc";
|
||||
const raw = String(check.Type || "")
|
||||
.trim()
|
||||
.toLocaleLowerCase();
|
||||
if (raw) return raw;
|
||||
return check.ServiceID ? "service" : "node";
|
||||
}
|
||||
|
||||
function matchesHealthFilters(check: ConsulHealthCheck): boolean {
|
||||
if (statusFilter.value !== "any" && normalizedCheckStatus(check) !== statusFilter.value) return false;
|
||||
if (typeFilter.value !== "any" && checkType(check) !== typeFilter.value) return false;
|
||||
const query = keywordFilter.value.trim().toLocaleLowerCase();
|
||||
if (!query) return true;
|
||||
return [check.Name, check.CheckID, check.Node, check.ServiceName, check.ServiceID, check.Output, check.Notes, check.Type, check.ServiceTags.join(" "), JSON.stringify(check.Definition)].join(" ").toLocaleLowerCase().includes(query);
|
||||
}
|
||||
|
||||
const localCheckItems = computed(() => Object.values(localChecks.value).sort((left, right) => left.Name.localeCompare(right.Name) || left.CheckID.localeCompare(right.CheckID)));
|
||||
const filteredChecks = computed(() => checks.value.filter(matchesHealthFilters));
|
||||
const filteredLocalChecks = computed(() => localCheckItems.value.filter(matchesHealthFilters));
|
||||
const pagedChecks = computed(() => paginateConsulItems(filteredChecks.value, checkPage.value));
|
||||
const pagedLocalChecks = computed(() => paginateConsulItems(filteredLocalChecks.value, localCheckPage.value));
|
||||
const pagedServiceInstances = computed(() => paginateConsulItems(serviceInstances.value, serviceInstancePage.value));
|
||||
const availableCheckTypes = computed(() => [...new Set([...checks.value, ...localCheckItems.value].map(checkType))].sort());
|
||||
const healthSummary = computed(() => {
|
||||
const summary = { passing: 0, warning: 0, critical: 0, maintenance: 0 };
|
||||
for (const check of checks.value) {
|
||||
const status = normalizedCheckStatus(check);
|
||||
if (status in summary) summary[status as keyof typeof summary]++;
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
const hasActiveHealthFilters = computed(() => Boolean(keywordFilter.value.trim() || statusFilter.value !== "any" || typeFilter.value !== "any"));
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
function selectCheck(check: ConsulHealthCheck) {
|
||||
selectedCheck.value = selectedCheck.value?.CheckID === check.CheckID && selectedCheck.value?.Node === check.Node ? null : check;
|
||||
}
|
||||
function currentScope(): ConsulScope {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
return {
|
||||
datacenter: String(config.datacenter || config.consulDatacenter || config.consul_datacenter || ""),
|
||||
namespace: String(config.namespace || config.consulNamespace || config.consul_namespace || ""),
|
||||
partition: String(config.partition || config.consulPartition || config.consul_partition || ""),
|
||||
};
|
||||
}
|
||||
function newWatchId() {
|
||||
return globalThis.crypto?.randomUUID?.() || `consul-health-${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
function watchActive(operationId: string) {
|
||||
return watchEnabled.value && !watchPaused.value && watchOperationId === operationId && watchConnectionId === props.connectionId && watchGeneration === consulStore.generation;
|
||||
}
|
||||
async function stopWatch() {
|
||||
const operationId = watchOperationId;
|
||||
watchOperationId = "";
|
||||
if (!operationId) return;
|
||||
consulStore.completeOperation(operationId);
|
||||
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, operationId).catch(() => false);
|
||||
}
|
||||
function watchTarget(name: string) {
|
||||
if (browseMode.value === "node") return { kind: "healthNode" as const, node: name };
|
||||
if (browseMode.value === "service") return { kind: "healthServiceInstances" as const, service: name, passing: null };
|
||||
return { kind: "healthState" as const, state: name };
|
||||
}
|
||||
|
||||
async function runWatch(operationId: string, name: string, mode: typeof browseMode.value) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && browseMode.value === mode && (mode === "state" ? state.value : browseName.value) === name) {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: watchTarget(name),
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<ConsulHealthCheck[] | ConsulServiceInstance[]>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) {
|
||||
if (mode === "service") {
|
||||
serviceInstances.value = result.items as ConsulServiceInstance[];
|
||||
checks.value = serviceInstances.value.flatMap((instance) => instance.Checks);
|
||||
} else checks.value = result.items as ConsulHealthCheck[];
|
||||
selectedCheck.value = checks.value.find((check) => check.CheckID === selectedCheck.value?.CheckID && check.Node === selectedCheck.value?.Node) || null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
async function startWatch() {
|
||||
if (watchOperationId && watchEnabled.value && !watchPaused.value) return;
|
||||
watchEnabled.value = true;
|
||||
watchPaused.value = false;
|
||||
watchConnectionId = props.connectionId;
|
||||
watchScope = currentScope();
|
||||
consulStore.bindConnection(watchConnectionId, watchScope);
|
||||
watchGeneration = consulStore.generation;
|
||||
watchOperationId = newWatchId();
|
||||
consulStore.registerOperation(watchOperationId);
|
||||
const name = browseMode.value === "state" ? state.value : browseName.value;
|
||||
if (!name.trim()) {
|
||||
watchEnabled.value = false;
|
||||
consulStore.completeOperation(watchOperationId);
|
||||
watchOperationId = "";
|
||||
return;
|
||||
}
|
||||
void runWatch(watchOperationId, name, browseMode.value);
|
||||
}
|
||||
async function pauseWatch() {
|
||||
watchPaused.value = true;
|
||||
await stopWatch();
|
||||
}
|
||||
async function independent(name: string, action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
errors.value = { ...errors.value, [name]: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadHealthQuery() {
|
||||
const resume = watchEnabled.value && !watchPaused.value;
|
||||
await stopWatch();
|
||||
await load();
|
||||
if (resume && (browseMode.value === "state" || browseName.value.trim())) await startWatch();
|
||||
}
|
||||
|
||||
async function switchHealthBrowseMode(mode: typeof browseMode.value) {
|
||||
if (browseMode.value === mode) return;
|
||||
browseMode.value = mode;
|
||||
browseName.value = "";
|
||||
browseNameDraft.value = "";
|
||||
selectedCheck.value = null;
|
||||
await reloadHealthQuery();
|
||||
}
|
||||
|
||||
async function applyBrowseTarget() {
|
||||
const target = browseNameDraft.value.trim();
|
||||
if (!target) return;
|
||||
browseName.value = target;
|
||||
selectedCheck.value = null;
|
||||
await reloadHealthQuery();
|
||||
}
|
||||
|
||||
function clearHealthFilters() {
|
||||
keywordFilter.value = "";
|
||||
statusFilter.value = "any";
|
||||
typeFilter.value = "any";
|
||||
selectedCheck.value = null;
|
||||
}
|
||||
|
||||
function applyStatusFilter(status: string) {
|
||||
statusFilter.value = statusFilter.value === status ? "any" : status;
|
||||
}
|
||||
|
||||
function checkTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
ttl: t("consul.ui.ttl"),
|
||||
http: t("consul.ui.http"),
|
||||
tcp: t("consul.ui.tcp"),
|
||||
grpc: t("consul.ui.grpc"),
|
||||
docker: t("consul.ui.docker"),
|
||||
script: t("consul.ui.script"),
|
||||
maintenance: t("consul.ui.maintenance"),
|
||||
service: t("consul.ui.service"),
|
||||
node: t("consul.ui.node"),
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
await Promise.all([
|
||||
independent(t("consul.ui.healthChecks"), async () => {
|
||||
if (browseMode.value === "state") {
|
||||
const value = await api.consulHealthState(props.connectionId, state.value);
|
||||
if (current === sequence) {
|
||||
checks.value = value.items;
|
||||
serviceInstances.value = [];
|
||||
}
|
||||
} else if (browseMode.value === "node") {
|
||||
if (!browseName.value.trim()) {
|
||||
checks.value = [];
|
||||
return;
|
||||
}
|
||||
const value = await api.consulHealthNode(props.connectionId, browseName.value);
|
||||
if (current === sequence) {
|
||||
checks.value = value.items;
|
||||
serviceInstances.value = [];
|
||||
}
|
||||
} else {
|
||||
if (!browseName.value.trim()) {
|
||||
checks.value = [];
|
||||
serviceInstances.value = [];
|
||||
return;
|
||||
}
|
||||
const value = await api.consulHealthService(props.connectionId, browseName.value);
|
||||
if (current === sequence) {
|
||||
serviceInstances.value = value.items;
|
||||
checks.value = value.items.flatMap((instance) => instance.Checks);
|
||||
}
|
||||
}
|
||||
}),
|
||||
independent(t("consul.ui.agent"), async () => {
|
||||
const value = await api.consulAgentSelf(props.connectionId);
|
||||
if (current === sequence) identity.value = value;
|
||||
}),
|
||||
independent(t("consul.ui.localAgentChecks"), async () => {
|
||||
const value = await api.consulAgentChecks(props.connectionId);
|
||||
if (current === sequence) localChecks.value = value;
|
||||
}),
|
||||
]);
|
||||
if (current === sequence) {
|
||||
selectedCheck.value = checks.value.find((check) => check.CheckID === selectedCheck.value?.CheckID && check.Node === selectedCheck.value?.Node) || null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function definition(): ConsulAgentCheckDefinition {
|
||||
const common = { interval: form.value.interval, timeout: form.value.timeout };
|
||||
if (form.value.type === "http") return { type: "http", url: form.value.endpoint, method: "GET", ...common, tlsSkipVerify: false };
|
||||
if (form.value.type === "tcp") return { type: "tcp", address: form.value.endpoint, ...common };
|
||||
if (form.value.type === "grpc") return { type: "grpc", address: form.value.endpoint, ...common, tls: false };
|
||||
if (form.value.type === "docker") return { type: "docker", containerId: form.value.endpoint, shell: "", args: form.value.args.split(/\s+/).filter(Boolean), ...common };
|
||||
if (form.value.type === "script") return { type: "script", args: form.value.args.split(/\s+/).filter(Boolean), ...common };
|
||||
return { type: "ttl", ttl: form.value.ttl };
|
||||
}
|
||||
|
||||
async function registerCheck() {
|
||||
if (!canAgentWrite.value || !form.value.name.trim()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const registration: ConsulAgentCheckRegistration = { id: form.value.id.trim(), name: form.value.name.trim(), notes: "", serviceId: form.value.serviceId.trim(), status: "critical", definition: definition() };
|
||||
await api.consulAgentRegisterCheck(props.connectionId, registration);
|
||||
editorOpen.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
errors.value = { ...errors.value, [t("consul.ui.writeOperation")]: errorMessage(error) };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTtl(id: string, status: ConsulCheckStatus) {
|
||||
if (canAgentWrite.value)
|
||||
await independent(t("consul.ui.writeOperation"), async () => {
|
||||
await api.consulAgentUpdateTtl(props.connectionId, id, status);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function deregister(id: string) {
|
||||
if (canAgentWrite.value && window.confirm(t("consul.ui.deregisterCheck", { id, node: agentTarget.value?.node || t("consul.ui.targetAgent") })))
|
||||
await independent(t("consul.ui.writeOperation"), async () => {
|
||||
await api.consulAgentDeregisterCheck(props.connectionId, id);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
function statusClass(check: ConsulHealthCheck) {
|
||||
if (check.Maintenance) return "text-blue-700 dark:text-blue-300";
|
||||
if (check.Status === "passing") return "text-emerald-700 dark:text-emerald-300";
|
||||
if (check.Status === "warning") return "text-amber-700 dark:text-amber-300";
|
||||
return "text-destructive";
|
||||
}
|
||||
function statusLabel(check: ConsulHealthCheck) {
|
||||
if (check.Maintenance) return t("consul.ui.maintenance");
|
||||
if (check.Status === "passing") return t("consul.ui.passing");
|
||||
if (check.Status === "warning") return t("consul.ui.warning");
|
||||
if (check.Status === "critical") return t("consul.ui.critical");
|
||||
return t("consul.ui.unknown");
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
async () => {
|
||||
await stopWatch();
|
||||
watchEnabled.value = false;
|
||||
watchPaused.value = false;
|
||||
watchIndex.value = null;
|
||||
await load();
|
||||
},
|
||||
);
|
||||
watch(state, async () => {
|
||||
if (browseMode.value === "state") await reloadHealthQuery();
|
||||
});
|
||||
watch([keywordFilter, statusFilter, typeFilter], () => {
|
||||
checkPage.value = 1;
|
||||
localCheckPage.value = 1;
|
||||
selectedCheck.value = null;
|
||||
});
|
||||
watch(filteredChecks, (items) => {
|
||||
checkPage.value = clampConsulPage(checkPage.value, items.length);
|
||||
});
|
||||
watch(filteredLocalChecks, (items) => {
|
||||
localCheckPage.value = clampConsulPage(localCheckPage.value, items.length);
|
||||
});
|
||||
watch(serviceInstances, (items) => {
|
||||
serviceInstancePage.value = clampConsulPage(serviceInstancePage.value, items.length);
|
||||
});
|
||||
onMounted(load);
|
||||
onDeactivated(() => {
|
||||
watchPaused.value = true;
|
||||
void stopWatch();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
void stopWatch();
|
||||
});
|
||||
defineExpose({ refresh: () => (void load(), true) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto p-4">
|
||||
<header class="mb-4 flex flex-wrap items-start justify-between gap-3 border-b pb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold">{{ t("consul.ui.healthChecks") }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge :variant="canAgentWrite ? 'secondary' : 'outline'" :class="canAgentWrite ? 'text-emerald-700 dark:text-emerald-300' : 'border-amber-500/40 text-amber-700 dark:text-amber-300'">{{
|
||||
t("consul.ui.agentTarget", { target: canAgentWrite && agentTarget ? `${agentTarget.node} (${agentTarget.address})` : t("consul.ui.unknownWritesDisabled") })
|
||||
}}</Badge>
|
||||
<span v-if="watchEnabled">{{ watchPaused ? t("consul.ui.watchPaused") : t("consul.ui.watchingIndex", { index: watchIndex || "1" }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :title="watchEnabled && !watchPaused ? t('consul.ui.pauseWatch') : t('consul.ui.startWatch')" :disabled="browseMode !== 'state' && !browseName.trim()" @click="watchEnabled && !watchPaused ? pauseWatch() : startWatch()"
|
||||
><Pause v-if="watchEnabled && !watchPaused" class="h-3.5 w-3.5" /><Radio v-else class="h-3.5 w-3.5" />{{ watchEnabled && !watchPaused ? t("consul.ui.pauseWatch") : t("consul.ui.startWatch") }}</Button
|
||||
>
|
||||
<Button size="icon" variant="outline" class="h-8 w-8" :disabled="loading" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="!canAgentWrite" @click="editorOpen = !editorOpen"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.register") }}</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mb-4 rounded-lg border bg-muted/10 p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="grid shrink-0 grid-cols-3 text-xs">
|
||||
<Button size="sm" class="h-8 rounded-r-none" :variant="browseMode === 'state' ? 'default' : 'outline'" @click="switchHealthBrowseMode('state')">{{ t("consul.ui.state") }}</Button>
|
||||
<Button size="sm" class="h-8 rounded-none" :variant="browseMode === 'node' ? 'default' : 'outline'" @click="switchHealthBrowseMode('node')">{{ t("consul.ui.node") }}</Button>
|
||||
<Button size="sm" class="h-8 rounded-l-none" :variant="browseMode === 'service' ? 'default' : 'outline'" @click="switchHealthBrowseMode('service')">{{ t("consul.ui.service") }}</Button>
|
||||
</div>
|
||||
<select v-if="browseMode === 'state'" v-model="state" class="h-8 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allStates") }}</option>
|
||||
<option value="passing">{{ t("consul.ui.passing") }}</option>
|
||||
<option value="warning">{{ t("consul.ui.warning") }}</option>
|
||||
<option value="critical">{{ t("consul.ui.critical") }}</option>
|
||||
</select>
|
||||
<div v-else class="flex min-w-56 flex-1 sm:max-w-sm">
|
||||
<Input v-model="browseNameDraft" class="h-8 rounded-r-none text-xs" :placeholder="browseMode === 'node' ? t('consul.ui.nodeName') : t('consul.ui.serviceName')" @keydown.enter="applyBrowseTarget" />
|
||||
<Button size="sm" class="h-8 rounded-l-none px-3" :disabled="!browseNameDraft.trim()" @click="applyBrowseTarget">{{ t("consul.ui.query") }}</Button>
|
||||
</div>
|
||||
<div class="hidden h-6 w-px bg-border lg:block" />
|
||||
<div class="relative min-w-64 flex-1">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="keywordFilter" class="h-8 pl-8 text-xs" :placeholder="t('consul.ui.searchHealthChecks')" />
|
||||
</div>
|
||||
<select v-model="statusFilter" class="h-8 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allStates") }}</option>
|
||||
<option value="passing">{{ t("consul.ui.passing") }}</option>
|
||||
<option value="warning">{{ t("consul.ui.warning") }}</option>
|
||||
<option value="critical">{{ t("consul.ui.critical") }}</option>
|
||||
<option value="maintenance">{{ t("consul.ui.maintenance") }}</option>
|
||||
</select>
|
||||
<select v-model="typeFilter" class="h-8 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allCheckTypes") }}</option>
|
||||
<option v-for="type in availableCheckTypes" :key="type" :value="type">{{ checkTypeLabel(type) }}</option>
|
||||
</select>
|
||||
<Button v-if="hasActiveHealthFilters" size="sm" variant="ghost" class="h-8 gap-1 px-2 text-xs" @click="clearHealthFilters"><X class="h-3.5 w-3.5" />{{ t("consul.ui.clearFilters") }}</Button>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 border-t pt-3 text-xs">
|
||||
<span class="mr-1 text-muted-foreground">{{ t("consul.ui.healthResultCount", { filtered: filteredChecks.length, total: checks.length }) }}</span>
|
||||
<button type="button" @click="applyStatusFilter('passing')">
|
||||
<Badge variant="outline" :class="statusFilter === 'passing' ? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'text-emerald-700 dark:text-emerald-300'">{{ t("consul.ui.passing") }} {{ healthSummary.passing }}</Badge>
|
||||
</button>
|
||||
<button type="button" @click="applyStatusFilter('warning')">
|
||||
<Badge variant="outline" :class="statusFilter === 'warning' ? 'border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'text-amber-700 dark:text-amber-300'">{{ t("consul.ui.warning") }} {{ healthSummary.warning }}</Badge>
|
||||
</button>
|
||||
<button type="button" @click="applyStatusFilter('critical')">
|
||||
<Badge variant="outline" :class="statusFilter === 'critical' ? 'border-destructive/50 bg-destructive/10 text-destructive' : 'text-destructive'">{{ t("consul.ui.critical") }} {{ healthSummary.critical }}</Badge>
|
||||
</button>
|
||||
<button type="button" @click="applyStatusFilter('maintenance')">
|
||||
<Badge variant="outline" :class="statusFilter === 'maintenance' ? 'border-blue-500/50 bg-blue-500/10 text-blue-700 dark:text-blue-300' : ''">{{ t("consul.ui.maintenance") }} {{ healthSummary.maintenance }}</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="Object.keys(errors).length" class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div v-for="(error, name) in errors" :key="name">
|
||||
<strong>{{ name }}:</strong> {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="editorOpen" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="border-b bg-muted/30 px-4 py-3">
|
||||
<h3 class="text-sm font-medium">{{ t("consul.ui.register") }}</h3>
|
||||
<p class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ t("consul.ui.checkRegistrationHint", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }}</p>
|
||||
</div>
|
||||
<div class="grid gap-x-5 gap-y-4 p-4 md:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.name") }}</Label
|
||||
><Input v-model="form.name" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.id") }}</Label
|
||||
><Input v-model="form.id" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.serviceId") }}</Label
|
||||
><Input v-model="form.serviceId" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.type") }}</Label
|
||||
><select v-model="form.type" class="h-9 w-full rounded-md border bg-background px-2 text-sm">
|
||||
<option value="ttl">{{ t("consul.ui.ttl") }}</option>
|
||||
<option value="http">{{ t("consul.ui.http") }}</option>
|
||||
<option value="tcp">{{ t("consul.ui.tcp") }}</option>
|
||||
<option value="grpc">{{ t("consul.ui.grpc") }}</option>
|
||||
<option value="docker">{{ t("consul.ui.docker") }}</option>
|
||||
<option value="script">{{ t("consul.ui.script") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.type === 'ttl'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.ttl") }}</Label
|
||||
><Input v-model="form.ttl" class="h-9" />
|
||||
</div>
|
||||
<div v-else-if="form.type === 'script'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.arguments") }}</Label
|
||||
><Input v-model="form.args" class="h-9" />
|
||||
</div>
|
||||
<div v-else class="space-y-1.5">
|
||||
<Label class="text-xs">{{ form.type === "docker" ? t("consul.ui.containerId") : t("consul.ui.endpoint") }}</Label
|
||||
><Input v-model="form.endpoint" class="h-9" />
|
||||
</div>
|
||||
<template v-if="form.type !== 'ttl'">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.interval") }}</Label
|
||||
><Input v-model="form.interval" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.timeout") }}</Label
|
||||
><Input v-model="form.timeout" class="h-9" />
|
||||
</div>
|
||||
<div v-if="form.type === 'docker'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.commandArguments") }}</Label
|
||||
><Input v-model="form.args" class="h-9" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 border-t bg-muted/10 px-4 py-3">
|
||||
<Button size="sm" variant="outline" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button size="sm" :disabled="saving || !canAgentWrite || !form.name.trim()" @click="registerCheck"><Loader2 v-if="saving" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("consul.ui.registerOn", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ browseMode === "state" ? t("consul.ui.clusterHealth") : browseMode === "node" ? t("consul.ui.nodeHealth", { node: browseName || "-" }) : t("consul.ui.serviceHealth", { service: browseName || "-" }) }}</span
|
||||
><Badge variant="secondary">{{ filteredChecks.length }}</Badge>
|
||||
</div>
|
||||
<ConsulListPagination
|
||||
:total="filteredChecks.length"
|
||||
:page="checkPage"
|
||||
:page-size="CONSUL_LIST_PAGE_SIZE"
|
||||
@update:page="
|
||||
checkPage = $event;
|
||||
selectedCheck = null;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-for="check in pagedChecks"
|
||||
:key="`${check.Node}-${check.CheckID}`"
|
||||
type="button"
|
||||
class="grid min-h-16 w-full grid-cols-[2rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2 border-b px-4 py-3 text-left text-xs last:border-0 hover:bg-muted/30 focus-visible:bg-muted/30 focus-visible:outline-none"
|
||||
:class="selectedCheck?.CheckID === check.CheckID && selectedCheck?.Node === check.Node && 'bg-muted/40'"
|
||||
:aria-expanded="selectedCheck?.CheckID === check.CheckID && selectedCheck?.Node === check.Node"
|
||||
@click="selectCheck(check)"
|
||||
>
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60"
|
||||
><CircleCheck v-if="check.Status === 'passing' && !check.Maintenance" class="h-4 w-4 text-emerald-600" /><TriangleAlert v-else-if="check.Status === 'warning' || check.Maintenance" class="h-4 w-4 text-amber-600" /><CircleX v-else class="h-4 w-4 text-destructive"
|
||||
/></span>
|
||||
<span class="grid min-w-0 gap-x-8 gap-y-2 md:grid-cols-[minmax(12rem,1.1fr)_minmax(10rem,.75fr)] xl:grid-cols-[minmax(14rem,1fr)_minmax(10rem,.65fr)_minmax(14rem,1.15fr)]">
|
||||
<span class="min-w-0">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-2"
|
||||
><span class="truncate font-medium">{{ check.Name }}</span
|
||||
><Badge variant="outline" class="h-5 px-1.5 text-[10px]" :class="statusClass(check)">{{ statusLabel(check) }}</Badge
|
||||
><Badge variant="secondary" class="h-5 px-1.5 text-[10px]">{{ checkTypeLabel(checkType(check)) }}</Badge></span
|
||||
>
|
||||
<span class="mt-1 block truncate font-mono text-[11px] text-muted-foreground" :title="check.CheckID">{{ check.CheckID }}</span>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-[10px] uppercase tracking-wide text-muted-foreground">{{ check.ServiceID ? t("consul.ui.service") : t("consul.ui.node") }}</span>
|
||||
<span class="mt-1 block truncate font-medium" :title="check.ServiceName || check.Node">{{ check.ServiceName || check.Node }}</span>
|
||||
<span v-if="check.ServiceID" class="mt-0.5 block truncate text-muted-foreground" :title="check.Node">{{ check.Node }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 md:col-span-2 xl:col-span-1">
|
||||
<span class="block text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("consul.ui.output") }}</span>
|
||||
<span class="mt-1 block truncate text-muted-foreground" :title="check.Output || check.Notes">{{ check.Output || check.Notes || "-" }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!filteredChecks.length && !loading" class="px-3 py-8 text-center text-xs text-muted-foreground">{{ t("consul.ui.noMatchingHealthChecks") }}</div>
|
||||
<div v-if="loading" class="flex items-center justify-center px-3 py-8 text-xs text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
</section>
|
||||
<section v-if="selectedCheck" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.checkDetails", { check: selectedCheck.Name }) }}</span
|
||||
><Button size="icon" variant="ghost" class="h-7 w-7" :title="t('consul.ui.close')" @click="selectedCheck = null"><CircleX class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<dl class="grid text-xs md:grid-cols-2">
|
||||
<div class="border-b px-3 py-3 md:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.checkId") }}</dt>
|
||||
<dd class="mt-1 break-all font-mono">{{ selectedCheck.CheckID }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.serviceId") }}</dt>
|
||||
<dd class="mt-1 break-all font-mono">{{ selectedCheck.ServiceID || "-" }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3 md:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.notes") }}</dt>
|
||||
<dd class="mt-1 whitespace-pre-wrap break-words">{{ selectedCheck.Notes || "-" }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.output") }}</dt>
|
||||
<dd class="mt-1 whitespace-pre-wrap break-words">{{ selectedCheck.Output || "-" }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3 md:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.definition") }}</dt>
|
||||
<dd class="mt-1 break-all font-mono">{{ JSON.stringify(selectedCheck.Definition) }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-3">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.indexes") }}</dt>
|
||||
<dd class="mt-1 font-mono">{{ selectedCheck.CreateIndex }} / {{ selectedCheck.ModifyIndex }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section v-if="browseMode === 'service' && serviceInstances.length" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.serviceInstances") }}</span
|
||||
><Badge variant="secondary">{{ serviceInstances.length }}</Badge>
|
||||
</div>
|
||||
<ConsulListPagination :total="serviceInstances.length" :page="serviceInstancePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="serviceInstancePage = $event" />
|
||||
</div>
|
||||
<div v-for="instance in pagedServiceInstances" :key="`${instance.Node.Node}-${instance.Service.ID}`" class="border-b px-3 py-2.5 text-xs last:border-0">
|
||||
<span class="font-medium">{{ instance.Node.Node }}</span
|
||||
><span class="ml-2 text-muted-foreground">{{ instance.Service.Address || instance.Node.Address }}:{{ instance.Service.Port }} · {{ instance.Service.ID }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between gap-2 border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.localAgentChecks") }}</span
|
||||
><Badge variant="secondary"
|
||||
>{{ filteredLocalChecks.length }}<span v-if="hasActiveHealthFilters"> / {{ localCheckItems.length }}</span></Badge
|
||||
>
|
||||
</div>
|
||||
<ConsulListPagination :total="filteredLocalChecks.length" :page="localCheckPage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="localCheckPage = $event" />
|
||||
</div>
|
||||
<div v-for="check in pagedLocalChecks" :key="check.CheckID" class="grid min-h-16 gap-3 border-b px-4 py-3 text-xs last:border-0 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div class="grid min-w-0 grid-cols-[2rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2 sm:grid-cols-[2rem_minmax(12rem,1fr)_minmax(10rem,.7fr)] xl:grid-cols-[2rem_minmax(13rem,.9fr)_minmax(10rem,.65fr)_minmax(13rem,1fr)]">
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-full bg-muted/60"
|
||||
><CircleCheck v-if="check.Status === 'passing' && !check.Maintenance" class="h-4 w-4 text-emerald-600" /><TriangleAlert v-else-if="check.Status === 'warning' || check.Maintenance" class="h-4 w-4 text-amber-600" /><CircleX v-else class="h-4 w-4 text-destructive"
|
||||
/></span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate font-medium">{{ check.Name }}</span
|
||||
><Badge variant="outline" class="h-5 px-1.5 text-[10px]" :class="statusClass(check)">{{ statusLabel(check) }}</Badge
|
||||
><Badge variant="secondary" class="h-5 px-1.5 text-[10px]">{{ checkTypeLabel(checkType(check)) }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 truncate font-mono text-[11px] text-muted-foreground" :title="check.CheckID">{{ check.CheckID }}</div>
|
||||
</div>
|
||||
<div class="col-start-2 min-w-0 sm:col-start-auto">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ check.ServiceID ? t("consul.ui.service") : t("consul.ui.node") }}</div>
|
||||
<div class="mt-1 truncate font-medium" :title="check.ServiceName || check.Node">{{ check.ServiceName || check.Node }}</div>
|
||||
<div v-if="check.ServiceID" class="mt-0.5 truncate text-muted-foreground" :title="check.Node">{{ check.Node }}</div>
|
||||
</div>
|
||||
<div class="col-start-2 min-w-0 sm:col-span-2 xl:col-span-1 xl:col-start-auto">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("consul.ui.output") }}</div>
|
||||
<div class="mt-1 truncate text-muted-foreground" :title="check.Output || check.Notes">{{ check.Output || check.Notes || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-0.5 border-t pt-2 lg:border-l lg:border-t-0 lg:pl-3 lg:pt-0">
|
||||
<template v-if="check.Definition?.TTL"
|
||||
><Button size="icon" variant="ghost" class="h-7 w-7 text-emerald-600" :disabled="!canAgentWrite" :title="t('consul.ui.ttlPass')" @click="updateTtl(check.CheckID, 'passing')"><CircleCheck class="h-3.5 w-3.5" /></Button
|
||||
><Button size="icon" variant="ghost" class="h-7 w-7 text-amber-600" :disabled="!canAgentWrite" :title="t('consul.ui.ttlWarn')" @click="updateTtl(check.CheckID, 'warning')"><TriangleAlert class="h-3.5 w-3.5" /></Button
|
||||
><Button size="icon" variant="ghost" class="h-7 w-7 text-destructive" :disabled="!canAgentWrite" :title="t('consul.ui.ttlFail')" @click="updateTtl(check.CheckID, 'critical')"><CircleX class="h-3.5 w-3.5" /></Button
|
||||
></template>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7 text-destructive" :disabled="!canAgentWrite" :title="t('consul.ui.deregister')" @click="deregister(check.CheckID)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filteredLocalChecks.length && !loading" class="px-3 py-8 text-center text-xs text-muted-foreground">{{ t("consul.ui.noMatchingHealthChecks") }}</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ChevronLeft, ChevronRight } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { clampConsulPage, consulPageCount } from "@/lib/consul/pagination";
|
||||
|
||||
const props = withDefaults(defineProps<{ total: number; page: number; pageSize: number; compact?: boolean }>(), {
|
||||
compact: false,
|
||||
});
|
||||
const emit = defineEmits<{ "update:page": [page: number] }>();
|
||||
const { t } = useI18n();
|
||||
const pages = computed(() => consulPageCount(props.total, props.pageSize));
|
||||
const current = computed(() => clampConsulPage(props.page, props.total, props.pageSize));
|
||||
const status = computed(() => t("consul.ui.pageStatus", { page: current.value, pages: pages.value, total: props.total }));
|
||||
|
||||
function move(delta: number) {
|
||||
emit("update:page", clampConsulPage(current.value + delta, props.total, props.pageSize));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="pages > 1" class="flex shrink-0 items-center justify-center gap-1 text-xs text-muted-foreground" :title="status">
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7" :disabled="current <= 1" :title="t('consul.ui.previousPage')" @click="move(-1)">
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span class="min-w-12 whitespace-nowrap text-center tabular-nums">{{ compact ? `${current} / ${pages}` : status }}</span>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7" :disabled="current >= pages" :title="t('consul.ui.nextPage')" @click="move(1)">
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { Clipboard, Eye, EyeOff, Loader2, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { ConsulCapabilities, ConsulConfigEntry, ConsulDiscoveryChain, ConsulExportedService, ConsulIntention, ConsulPeering } from "@/types/consul";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
type MeshTab = "config" | "intentions" | "discovery" | "peering" | "exported";
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const { t } = useI18n();
|
||||
function capabilityLabel(value?: string) {
|
||||
if (value === "supported") return t("consul.ui.capabilitySupported");
|
||||
if (value === "unsupported") return t("consul.ui.capabilityUnsupported");
|
||||
if (value === "disabled") return t("consul.ui.capabilityDisabled");
|
||||
if (value === "forbidden") return t("consul.ui.capabilityForbidden");
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const active = ref<MeshTab>("config");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const configKind = ref("service-defaults");
|
||||
const configs = ref<ConsulConfigEntry[]>([]);
|
||||
const intentions = ref<ConsulIntention[]>([]);
|
||||
const peerings = ref<ConsulPeering[]>([]);
|
||||
const exported = ref<ConsulExportedService[]>([]);
|
||||
const discoveryService = ref("");
|
||||
const chain = ref<ConsulDiscoveryChain | null>(null);
|
||||
const editorOpen = ref(false);
|
||||
const editorMode = ref<"config" | "intention" | "exported">("config");
|
||||
const editorJson = ref("{}");
|
||||
const editingConfig = ref<ConsulConfigEntry | null>(null);
|
||||
const peeringName = ref("");
|
||||
const peeringToken = ref("");
|
||||
const tokenOnce = ref("");
|
||||
const editingExported = ref<ConsulConfigEntry | null>(null);
|
||||
const tokenRevealed = ref(false);
|
||||
const editorOriginalJson = ref("");
|
||||
const savePreviewOpen = ref(false);
|
||||
const pendingEditorRaw = ref<Record<string, unknown> | null>(null);
|
||||
const intentionSource = ref("");
|
||||
const intentionDestination = ref("");
|
||||
const intentionCheckResult = ref<boolean | null>(null);
|
||||
const intentionMatches = ref<ConsulIntention[]>([]);
|
||||
let loadSequence = 0;
|
||||
const capability = computed(() =>
|
||||
active.value === "config" ? props.capabilities?.configEntries : active.value === "intentions" ? props.capabilities?.intentions : active.value === "peering" ? props.capabilities?.peering : active.value === "exported" ? props.capabilities?.exportedServices : "supported",
|
||||
);
|
||||
const canWrite = computed(() => !readOnly.value && capability.value === "supported");
|
||||
const chainRows = computed(() => {
|
||||
if (!chain.value?.Nodes) return [];
|
||||
const nodes = chain.value.Nodes;
|
||||
const rows: Array<{ id: string; depth: number; type: string; next: string[] }> = [];
|
||||
const seen = new Set<string>();
|
||||
const queue: Array<{ id: string; depth: number }> = [{ id: chain.value.StartNode || Object.keys(nodes)[0] || "", depth: 0 }];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
if (!current.id || seen.has(current.id)) continue;
|
||||
seen.add(current.id);
|
||||
const value = nodes[current.id];
|
||||
const next = collectNextNodes(value);
|
||||
rows.push({ id: current.id, depth: current.depth, type: nodeType(value), next });
|
||||
for (const id of next) if (nodes[id] && !seen.has(id)) queue.push({ id, depth: current.depth + 1 });
|
||||
}
|
||||
for (const id of Object.keys(nodes)) if (!seen.has(id)) rows.push({ id, depth: 0, type: nodeType(nodes[id]), next: collectNextNodes(nodes[id]) });
|
||||
return rows;
|
||||
});
|
||||
function nodeType(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "node";
|
||||
return String((value as Record<string, unknown>).Type || (value as Record<string, unknown>).type || "node");
|
||||
}
|
||||
function collectNextNodes(value: unknown): string[] {
|
||||
const found = new Set<string>();
|
||||
function visit(item: unknown) {
|
||||
if (Array.isArray(item)) {
|
||||
item.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (!item || typeof item !== "object") return;
|
||||
for (const [key, child] of Object.entries(item as Record<string, unknown>)) {
|
||||
if (key.toLowerCase() === "nextnode" && typeof child === "string" && child) found.add(child);
|
||||
else visit(child);
|
||||
}
|
||||
}
|
||||
visit(value);
|
||||
return [...found];
|
||||
}
|
||||
async function load() {
|
||||
const sequence = ++loadSequence;
|
||||
const connectionId = props.connectionId;
|
||||
const tab = active.value;
|
||||
const kind = configKind.value;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (tab === "config") {
|
||||
const value = await api.consulMeshConfigList(connectionId, kind);
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) configs.value = value;
|
||||
} else if (tab === "intentions") {
|
||||
const value = await api.consulMeshIntentionsList(connectionId);
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) intentions.value = value;
|
||||
} else if (tab === "peering") {
|
||||
const value = await api.consulMeshPeeringList(connectionId);
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) peerings.value = value;
|
||||
} else if (tab === "exported") {
|
||||
const value = await api.consulMeshExportedServicesList(connectionId);
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) exported.value = value;
|
||||
}
|
||||
} catch (cause) {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && tab === active.value) loading.value = false;
|
||||
}
|
||||
}
|
||||
function openConfig(item?: ConsulConfigEntry) {
|
||||
editorMode.value = "config";
|
||||
editingConfig.value = item || null;
|
||||
editorOriginalJson.value = item ? JSON.stringify(item.raw, null, 2) : "";
|
||||
editorJson.value = JSON.stringify(item?.raw || { Kind: configKind.value, Name: "" }, null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
async function openIntention(item?: ConsulIntention) {
|
||||
let value = item;
|
||||
if (item && !item.ID && item.SourceName && item.DestinationName) value = await api.consulMeshIntentionGetExact(props.connectionId, { source: item.SourceName, destination: item.DestinationName });
|
||||
editorMode.value = "intention";
|
||||
editorOriginalJson.value = value ? JSON.stringify(value, null, 2) : "";
|
||||
editorJson.value = JSON.stringify(value || { SourceName: "", DestinationName: "", Action: "allow", Permissions: [] }, null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
async function openExported() {
|
||||
error.value = "";
|
||||
try {
|
||||
const entries = await api.consulMeshConfigList(props.connectionId, "exported-services");
|
||||
editingExported.value = entries[0] || null;
|
||||
const raw = editingExported.value?.raw || { Kind: "exported-services", Name: "default", Services: [] };
|
||||
editorMode.value = "exported";
|
||||
editorOriginalJson.value = editingExported.value ? JSON.stringify(raw, null, 2) : "";
|
||||
editorJson.value = JSON.stringify(raw, null, 2);
|
||||
editorOpen.value = true;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
try {
|
||||
pendingEditorRaw.value = JSON.parse(editorJson.value) as Record<string, unknown>;
|
||||
savePreviewOpen.value = true;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function confirmSave() {
|
||||
const raw = pendingEditorRaw.value;
|
||||
if (!raw) return;
|
||||
try {
|
||||
if (editorMode.value === "config") {
|
||||
const kind = String(raw.Kind || configKind.value),
|
||||
name = String(raw.Name || "");
|
||||
await api.consulMeshConfigApply(props.connectionId, { kind, name, expectedModifyIndex: editingConfig.value?.modifyIndex || 0, raw });
|
||||
} else if (editorMode.value === "intention") await api.consulMeshIntentionUpsert(props.connectionId, raw as ConsulIntention);
|
||||
else await api.consulMeshExportedServicesApply(props.connectionId, String(raw.Name || "default"), editingExported.value?.modifyIndex || 0, raw);
|
||||
savePreviewOpen.value = false;
|
||||
editorOpen.value = false;
|
||||
pendingEditorRaw.value = null;
|
||||
editingExported.value = null;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function removeConfig(item: ConsulConfigEntry) {
|
||||
if (!confirm(t("consul.ui.deleteConfig", { kind: item.kind, name: item.name }))) return;
|
||||
await api.consulMeshConfigDelete(props.connectionId, item.kind, item.name, item.modifyIndex);
|
||||
await load();
|
||||
}
|
||||
async function removeIntention(item: ConsulIntention) {
|
||||
if (item.ID) {
|
||||
if (!confirm(t("consul.ui.deleteIntention", { id: item.ID }))) return;
|
||||
await api.consulMeshIntentionDelete(props.connectionId, item.ID);
|
||||
} else if (item.SourceName && item.DestinationName) {
|
||||
if (!confirm(t("consul.ui.deleteIntentionExact", { source: item.SourceName, destination: item.DestinationName }))) return;
|
||||
await api.consulMeshIntentionDeleteExact(props.connectionId, { source: item.SourceName, destination: item.DestinationName });
|
||||
} else return;
|
||||
await load();
|
||||
}
|
||||
function permissionsSummary(item: ConsulIntention) {
|
||||
return item.Permissions?.length ? t("consul.ui.permissions", { count: item.Permissions.length }) : t("consul.ui.actionRule");
|
||||
}
|
||||
function intentionActionLabel(action?: string) {
|
||||
return action === "allow" ? t("consul.ui.allowed") : action === "deny" ? t("consul.ui.denied") : action || t("consul.ui.unknown");
|
||||
}
|
||||
function peeringStateLabel(state?: string) {
|
||||
const value = String(state || "").toLowerCase();
|
||||
if (value === "pending") return t("consul.ui.peeringPending");
|
||||
if (value === "establishing") return t("consul.ui.peeringEstablishing");
|
||||
if (value === "active") return t("consul.ui.peeringActive");
|
||||
if (value === "failing") return t("consul.ui.peeringFailing");
|
||||
if (value === "deleting") return t("consul.ui.peeringDeleting");
|
||||
if (value === "terminated") return t("consul.ui.peeringTerminated");
|
||||
return state || t("consul.ui.unknown");
|
||||
}
|
||||
async function checkIntention() {
|
||||
if (!intentionSource.value.trim() || !intentionDestination.value.trim()) return;
|
||||
try {
|
||||
const result = await api.consulMeshIntentionCheck(props.connectionId, { source: intentionSource.value.trim(), destination: intentionDestination.value.trim() });
|
||||
intentionCheckResult.value = result.Allowed;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function matchIntentions(by: "source" | "destination") {
|
||||
const name = (by === "source" ? intentionSource.value : intentionDestination.value).trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
intentionMatches.value = await api.consulMeshIntentionMatch(props.connectionId, { by, name });
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function loadChain() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
chain.value = await api.consulMeshDiscoveryChain(props.connectionId, discoveryService.value);
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function clearToken() {
|
||||
tokenOnce.value = "";
|
||||
tokenRevealed.value = false;
|
||||
}
|
||||
async function generateToken() {
|
||||
try {
|
||||
const result = await api.consulMeshPeeringGenerateToken(props.connectionId, { PeerName: peeringName.value });
|
||||
tokenOnce.value = result.PeeringToken;
|
||||
tokenRevealed.value = false;
|
||||
peeringName.value = "";
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function establishPeering() {
|
||||
try {
|
||||
await api.consulMeshPeeringEstablish(props.connectionId, { PeerName: peeringName.value, PeeringToken: peeringToken.value });
|
||||
peeringToken.value = "";
|
||||
peeringName.value = "";
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function copyToken() {
|
||||
await navigator.clipboard.writeText(tokenOnce.value);
|
||||
clearToken();
|
||||
}
|
||||
async function removePeering(name: string) {
|
||||
if (!confirm(t("consul.ui.deletePeering", { name }))) return;
|
||||
await api.consulMeshPeeringDelete(props.connectionId, name);
|
||||
await load();
|
||||
}
|
||||
watch([active, () => props.connectionId], () => {
|
||||
clearToken();
|
||||
void load();
|
||||
});
|
||||
onMounted(() => void load());
|
||||
onDeactivated(clearToken);
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
clearToken();
|
||||
});
|
||||
defineExpose({
|
||||
refresh: () => {
|
||||
void load();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Tabs v-model="active" class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex h-11 items-center justify-between border-b px-3">
|
||||
<TabsList class="h-8 p-0.5"
|
||||
><TabsTrigger value="config" class="h-7 px-2 text-xs">{{ t("consul.ui.configEntries") }}</TabsTrigger
|
||||
><TabsTrigger value="intentions" class="h-7 px-2 text-xs">{{ t("consul.ui.intentions") }}</TabsTrigger
|
||||
><TabsTrigger value="discovery" class="h-7 px-2 text-xs">{{ t("consul.ui.discoveryChain") }}</TabsTrigger
|
||||
><TabsTrigger value="peering" class="h-7 px-2 text-xs">{{ t("consul.ui.peering") }}</TabsTrigger
|
||||
><TabsTrigger value="exported" class="h-7 px-2 text-xs">{{ t("consul.ui.exportedServices") }}</TabsTrigger></TabsList
|
||||
><Button variant="ghost" size="icon" class="h-7 w-7" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="capability !== 'supported'" class="m-4 border p-3 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capability) }) }}</div>
|
||||
<div v-else-if="error" class="m-4 border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ error }}</div>
|
||||
<div v-if="loading" class="flex h-24 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<TabsContent value="config" class="m-0 min-h-0 flex-1 overflow-auto p-4"
|
||||
><div class="mb-3 flex gap-2">
|
||||
<Input v-model="configKind" class="max-w-xs" :placeholder="t('consul.ui.configEntryKind')" /><Button variant="outline" size="sm" @click="load">{{ t("consul.ui.loadKind") }}</Button
|
||||
><Button v-if="canWrite" size="sm" class="gap-1" @click="openConfig()"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.newJson") }}</Button>
|
||||
</div>
|
||||
<div class="divide-y border">
|
||||
<div v-for="item in configs" :key="`${item.kind}/${item.name}`" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ item.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ item.kind }} · {{ t("consul.ui.modifyIndex", { index: item.modifyIndex }) }}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="openConfig(item)">{{ t("consul.ui.rawJson") }}</Button
|
||||
><Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="removeConfig(item)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div></div
|
||||
></TabsContent>
|
||||
<TabsContent value="intentions" class="m-0 min-h-0 flex-1 overflow-auto p-4">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Button v-if="canWrite" size="sm" class="gap-1" @click="openIntention()"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.newIntention") }}</Button>
|
||||
<Input v-model="intentionSource" class="max-w-48" :placeholder="t('consul.ui.sourceService')" />
|
||||
<Input v-model="intentionDestination" class="max-w-48" :placeholder="t('consul.ui.destinationService')" />
|
||||
<Button variant="outline" size="sm" @click="checkIntention">{{ t("consul.ui.check") }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="matchIntentions('source')">{{ t("consul.ui.matchSource") }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="matchIntentions('destination')">{{ t("consul.ui.matchDestination") }}</Button>
|
||||
<span v-if="intentionCheckResult !== null" class="text-xs font-medium" :class="intentionCheckResult ? 'text-emerald-600' : 'text-destructive'">{{ intentionCheckResult ? t("consul.ui.allowed") : t("consul.ui.denied") }}</span>
|
||||
</div>
|
||||
<div v-if="intentionMatches.length" class="mb-3 border bg-muted/20 p-2 text-xs">{{ t("consul.ui.matchedIntentions", { count: intentionMatches.length }) }}</div>
|
||||
<div class="divide-y border">
|
||||
<div v-for="item in intentionMatches.length ? intentionMatches : intentions" :key="item.ID || `${item.SourceName}:${item.DestinationName}`" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ item.SourceName }} → {{ item.DestinationName }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ intentionActionLabel(item.Action) }} · {{ permissionsSummary(item) }} · {{ item.SourcePartition || t("consul.ui.default") }}/{{ item.SourceNamespace || t("consul.ui.default") }} → {{ item.DestinationPartition || t("consul.ui.default") }}/{{
|
||||
item.DestinationNamespace || t("consul.ui.default")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="openIntention(item)">{{ t("consul.ui.details") }}</Button
|
||||
><Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="removeIntention(item)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="discovery" class="m-0 min-h-0 flex-1 overflow-auto p-4">
|
||||
<div class="flex max-w-xl gap-2">
|
||||
<Input v-model="discoveryService" :placeholder="t('consul.ui.serviceName')" @keyup.enter="loadChain" /><Button @click="loadChain">{{ t("consul.ui.compile") }}</Button>
|
||||
</div>
|
||||
<div v-if="chain" class="mt-4 space-y-3">
|
||||
<div class="border bg-muted/20 px-3 py-2 text-xs">{{ chain.ServiceName }} · {{ chain.Protocol || t("consul.ui.defaultProtocol") }} · {{ chain.Datacenter || t("consul.ui.configuredDatacenter") }}</div>
|
||||
<div class="divide-y border">
|
||||
<div v-for="row in chainRows" :key="row.id" class="grid min-h-12 grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)] items-center gap-3 px-3 py-2 text-xs" :style="{ paddingLeft: `${12 + row.depth * 20}px` }">
|
||||
<div class="min-w-0 truncate font-mono">
|
||||
<span v-if="row.id === chain.StartNode" class="mr-1 text-emerald-600">{{ t("consul.ui.start") }}</span
|
||||
>{{ row.id }}
|
||||
</div>
|
||||
<Badge variant="outline" class="w-fit">{{ row.type }}</Badge>
|
||||
<div class="truncate text-muted-foreground">{{ row.next.length ? t("consul.ui.next", { nodes: row.next.join(", ") }) : t("consul.ui.terminalTarget") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<details v-if="chain.Targets && Object.keys(chain.Targets).length" class="border">
|
||||
<summary class="cursor-pointer px-3 py-2 text-xs font-medium">{{ t("consul.ui.upstreamTargets", { count: Object.keys(chain.Targets).length }) }}</summary>
|
||||
<pre class="max-h-64 overflow-auto border-t bg-muted/20 p-3 text-xs">{{ JSON.stringify(chain.Targets, null, 2) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="peering" class="m-0 min-h-0 flex-1 overflow-auto p-4"
|
||||
><div v-if="canWrite" class="mb-3 grid max-w-2xl grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_auto_auto] gap-2">
|
||||
<Input v-model="peeringName" :placeholder="t('consul.ui.peerName')" /><PasswordInput v-model="peeringToken" :placeholder="t('consul.ui.peeringToken')" /><Button :disabled="!peeringName" variant="outline" @click="generateToken">{{ t("consul.ui.generateToken") }}</Button
|
||||
><Button :disabled="!peeringName || !peeringToken" @click="establishPeering">{{ t("consul.ui.establish") }}</Button>
|
||||
</div>
|
||||
<div v-if="tokenOnce" class="mb-3 flex items-center gap-2 border border-amber-300 bg-amber-50 p-3 text-sm dark:bg-amber-950/30">
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ tokenRevealed ? tokenOnce : "****************" }}</span
|
||||
><Button variant="ghost" size="icon" class="h-7 w-7" :title="tokenRevealed ? t('consul.ui.hide') : t('consul.ui.reveal')" @click="tokenRevealed = !tokenRevealed"><EyeOff v-if="tokenRevealed" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button
|
||||
><Button size="sm" class="gap-1" @click="copyToken"><Clipboard class="h-3.5 w-3.5" />{{ t("consul.ui.copyOnce") }}</Button
|
||||
><Button variant="ghost" size="sm" @click="clearToken">{{ t("consul.ui.hide") }}</Button>
|
||||
</div>
|
||||
<div class="divide-y border">
|
||||
<div v-for="item in peerings" :key="item.Name" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{{ item.Name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ peeringStateLabel(item.State) }} · {{ item.Partition || t("consul.ui.default") }}</div>
|
||||
</div>
|
||||
<Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="removePeering(String(item.Name))"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div></div
|
||||
></TabsContent>
|
||||
<TabsContent value="exported" class="m-0 min-h-0 flex-1 overflow-auto p-4"
|
||||
><Button v-if="canWrite" size="sm" class="mb-3 gap-1" @click="openExported"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.editExportedServices") }}</Button>
|
||||
<pre class="max-h-[60vh] overflow-auto border bg-muted/20 p-3 text-xs">{{ JSON.stringify(exported, null, 2) }}</pre>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Dialog v-model:open="editorOpen"
|
||||
><DialogContent class="max-w-3xl"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ editorMode === "config" ? t("consul.ui.serviceMeshConfigEntry") : editorMode === "intention" ? t("consul.ui.intentions") : t("consul.ui.exportedServices") }}</DialogTitle></DialogHeader
|
||||
><textarea v-model="editorJson" class="h-96 w-full resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none" spellcheck="false" /><DialogFooter
|
||||
><Button variant="outline" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button v-if="canWrite" @click="save">{{ t("consul.ui.applyWithCas") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
<NacosConfigDiffDialog v-model:open="savePreviewOpen" :title="t('consul.ui.reviewMeshChanges')" :before="editorOriginalJson" :after="editorJson" :confirm-label="t('consul.ui.apply')" @confirm="confirmSave" />
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { Download, RefreshCcw } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulCapabilities, ConsulOperatorDocument, ConsulOperatorReadKind } from "@/types/consul";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const kinds: ConsulOperatorReadKind[] = ["autopilot_configuration", "autopilot_health", "autopilot_state", "raft_configuration", "usage", "license", "audit"];
|
||||
const documents = ref<Record<string, ConsulOperatorDocument>>({});
|
||||
const failures = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const writeError = ref("");
|
||||
const snapshot = ref({ data: "", datacenter: "", confirmation: "" });
|
||||
const autopilot = ref({ cleanupDeadServers: true, confirmation: "" });
|
||||
const raft = ref({ serverId: "", address: "", confirmation: "" });
|
||||
const keyring = ref({ operation: "install" as "install" | "use" | "remove", key: "", confirmation: "" });
|
||||
const license = ref({ value: "", confirmation: "" });
|
||||
const settings = computed(() => {
|
||||
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
||||
return external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
});
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const autopilotWriteVisible = computed(() => settings.value.consulOperatorAutopilotWriteEnabled && props.capabilities?.operatorAutopilot === "supported");
|
||||
const raftWriteVisible = computed(() => settings.value.consulOperatorRaftWriteEnabled && props.capabilities?.operatorRaft === "supported");
|
||||
const keyringWriteVisible = computed(() => settings.value.consulOperatorKeyringWriteEnabled && props.capabilities?.operatorKeyring === "supported");
|
||||
const licenseWriteVisible = computed(() => settings.value.consulOperatorLicenseWriteEnabled && props.capabilities?.operatorLicense === "supported");
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
await Promise.all(
|
||||
kinds.map(async (kind) => {
|
||||
try {
|
||||
documents.value[kind] = await api.consulOperatorRead(props.connectionId, kind);
|
||||
delete failures.value[kind];
|
||||
} catch (error) {
|
||||
failures.value[kind] = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function downloadSnapshot() {
|
||||
const snapshot = await api.consulSnapshotGenerate(props.connectionId);
|
||||
const binary = atob(snapshot.dataBase64);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
|
||||
link.download = `consul-${snapshot.datacenter || "snapshot"}.snap`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
async function selectSnapshot(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (bytes.length < 10 || bytes[0] !== 0x1f || bytes[1] !== 0x8b || bytes[2] !== 0x08) {
|
||||
snapshot.value.data = "";
|
||||
writeError.value = "CONSUL_SNAPSHOT_INVALID: Snapshot must be a gzip archive generated by Consul";
|
||||
return;
|
||||
}
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
snapshot.value.data = btoa(binary);
|
||||
}
|
||||
|
||||
async function runWrite(action: () => Promise<void>) {
|
||||
writeError.value = "";
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
writeError.value = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto">
|
||||
<div class="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background px-3">
|
||||
<span class="text-sm font-medium">{{ t("consul.ui.operatorReadOnly") }}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="outline" size="sm" @click="downloadSnapshot"><Download class="mr-1 h-3.5 w-3.5" />{{ t("consul.ui.snapshot") }}</Button
|
||||
><Button variant="ghost" size="icon" class="h-7 w-7" :disabled="loading" :title="t('consul.ui.refresh')" @click="refresh"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 p-3 lg:grid-cols-2">
|
||||
<section v-for="kind in kinds" :key="kind" class="border">
|
||||
<h3 class="border-b px-3 py-2 text-xs font-medium uppercase">{{ kind.replaceAll("_", " ") }}</h3>
|
||||
<p v-if="failures[kind]" class="px-3 py-3 text-xs text-destructive">{{ failures[kind] }}</p>
|
||||
<dl v-else class="divide-y">
|
||||
<div v-for="field in documents[kind]?.fields || []" :key="field.name" class="grid grid-cols-[minmax(8rem,1fr)_2fr] gap-3 px-3 py-2 text-xs">
|
||||
<dt class="text-muted-foreground">{{ field.name }}</dt>
|
||||
<dd class="break-all">{{ field.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="writeError" class="mx-3 mb-3 border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">{{ writeError }}</div>
|
||||
<div v-if="!readOnly" class="grid gap-3 px-3 pb-3 lg:grid-cols-2">
|
||||
<section v-if="settings.consulOperatorSnapshotRestoreEnabled" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.snapshotRestore") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<Input type="file" @change="selectSnapshot" /><Input v-model="snapshot.datacenter" :placeholder="t('consul.ui.targetDatacenter')" /><Input v-model="snapshot.confirmation" :placeholder="t('consul.ui.restoreSnapshotConfirmation', { datacenter: snapshot.datacenter })" /><Button
|
||||
size="sm"
|
||||
:disabled="!snapshot.data"
|
||||
@click="runWrite(() => api.consulSnapshotRestore(props.connectionId, { snapshotBase64: snapshot.data, targetDatacenter: snapshot.datacenter, confirmation: snapshot.confirmation }))"
|
||||
>{{ t("consul.ui.restoreSnapshot") }}</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="autopilotWriteVisible" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.autopilotUpdate") }}</h3>
|
||||
<label class="mb-2 flex items-center gap-2 text-xs"><input v-model="autopilot.cleanupDeadServers" type="checkbox" />{{ t("consul.ui.cleanupDeadServers") }}</label
|
||||
><Input v-model="autopilot.confirmation" :placeholder="t('consul.ui.updateAutopilotConfirmation')" /><Button class="mt-2" size="sm" @click="runWrite(() => api.consulAutopilotUpdate(props.connectionId, { CleanupDeadServers: autopilot.cleanupDeadServers }, autopilot.confirmation))">{{
|
||||
t("consul.ui.updateAutopilot")
|
||||
}}</Button>
|
||||
</section>
|
||||
<section v-if="raftWriteVisible" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.raftOperations") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<Input v-model="raft.serverId" :placeholder="t('consul.ui.serverId')" /><Input v-model="raft.address" :placeholder="t('consul.ui.peerAddress')" /><Input v-model="raft.confirmation" :placeholder="t('consul.ui.reenterIdAddress')" />
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" @click="runWrite(() => api.consulRaftTransfer(props.connectionId, { serverId: raft.serverId || null, address: null, confirmation: raft.confirmation }))">{{ t("consul.ui.transferLeader") }}</Button
|
||||
><Button size="sm" variant="destructive" @click="runWrite(() => api.consulRaftRemove(props.connectionId, { serverId: raft.serverId || null, address: raft.address || null, confirmation: raft.confirmation }))">{{ t("consul.ui.removePeer") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="keyringWriteVisible" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.gossipKeyring") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t("consul.ui.operation") }}</Label
|
||||
><select v-model="keyring.operation" class="h-9 border bg-background px-2 text-sm">
|
||||
<option value="install">{{ t("consul.ui.install") }}</option>
|
||||
<option value="use">{{ t("consul.ui.use") }}</option>
|
||||
<option value="remove">{{ t("consul.ui.remove") }}</option></select
|
||||
><Input v-model="keyring.key" type="password" :placeholder="t('consul.ui.gossipKey')" /><Input v-model="keyring.confirmation" :placeholder="t('consul.ui.changeKeyringConfirmation')" /><Button size="sm" @click="runWrite(() => api.consulKeyringWrite(props.connectionId, keyring))">{{
|
||||
t("consul.ui.changeKeyring")
|
||||
}}</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="licenseWriteVisible" class="border p-3">
|
||||
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.licenseUpdate") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<Input v-model="license.value" type="password" :placeholder="t('consul.ui.license')" /><Input v-model="license.confirmation" :placeholder="t('consul.ui.updateLicenseConfirmation')" /><Button
|
||||
size="sm"
|
||||
@click="runWrite(() => api.consulLicenseWrite(props.connectionId, { license: license.value, confirmation: license.confirmation }))"
|
||||
>{{ t("consul.ui.updateLicense") }}</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, Loader2, RefreshCcw, Server } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulAgentIdentity, ConsulAgentMember, ConsulCapabilities, ConsulOperatorDocument } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const capabilities = ref<ConsulCapabilities | null>(null);
|
||||
const identity = ref<ConsulAgentIdentity | null>(null);
|
||||
const leader = ref<string | null>(null);
|
||||
const peers = ref<string[]>([]);
|
||||
const members = ref<ConsulAgentMember[]>([]);
|
||||
const autopilotHealth = ref<ConsulOperatorDocument | null>(null);
|
||||
const errors = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
let sequence = 0;
|
||||
type ProbeState = "loading" | "ready" | "forbidden" | "unsupported" | "error";
|
||||
const probeKeys = ["capabilities", "agent", "leader", "peers", "members", "autopilot"] as const;
|
||||
type ProbeKey = (typeof probeKeys)[number];
|
||||
const probeStates = ref<Record<ProbeKey, ProbeState>>({ capabilities: "loading", agent: "loading", leader: "loading", peers: "loading", members: "loading", autopilot: "loading" });
|
||||
|
||||
const memberSummary = computed(() => {
|
||||
const alive = members.value.filter((member) => member.Status === 1).length;
|
||||
return `${alive}/${members.value.length}`;
|
||||
});
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function classifyProbeError(error: unknown): ProbeState {
|
||||
const detail = message(error).toLowerCase();
|
||||
if (detail.includes("consul_permission_denied") || detail.includes("forbidden") || detail.includes("403")) return "forbidden";
|
||||
if (detail.includes("unsupported") || detail.includes("not found") || detail.includes("404")) return "unsupported";
|
||||
return "error";
|
||||
}
|
||||
|
||||
function probeLabel(key: ProbeKey) {
|
||||
return key === "capabilities" ? t("consul.ui.capabilities") : key === "agent" ? t("consul.ui.agent") : key === "leader" ? t("consul.ui.leader") : key === "peers" ? t("consul.ui.raftPeers") : key === "members" ? t("consul.ui.members") : t("consul.ui.autopilotHealth");
|
||||
}
|
||||
|
||||
function probeStateLabel(state: ProbeState) {
|
||||
return t(`consul.ui.probe${state[0].toUpperCase()}${state.slice(1)}`);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
probeStates.value = { capabilities: "loading", agent: "loading", leader: "loading", peers: "loading", members: "loading", autopilot: "loading" };
|
||||
const tasks = [
|
||||
["capabilities", api.consulCapabilities(props.connectionId), (value: ConsulCapabilities) => (capabilities.value = value)],
|
||||
["agent", api.consulAgentSelf(props.connectionId), (value: ConsulAgentIdentity) => (identity.value = value)],
|
||||
["leader", api.consulStatusLeader(props.connectionId), (value: string) => (leader.value = value)],
|
||||
["peers", api.consulStatusPeers(props.connectionId), (value: string[]) => (peers.value = value)],
|
||||
["members", api.consulAgentMembers(props.connectionId), (value: ConsulAgentMember[]) => (members.value = value)],
|
||||
["autopilot", api.consulOperatorRead(props.connectionId, "autopilot_health"), (value: ConsulOperatorDocument) => (autopilotHealth.value = value)],
|
||||
] as const;
|
||||
await Promise.all(
|
||||
tasks.map(async ([name, promise, assign]) => {
|
||||
try {
|
||||
const value = await promise;
|
||||
if (current === sequence) {
|
||||
(assign as (value: never) => void)(value as never);
|
||||
probeStates.value = { ...probeStates.value, [name]: "ready" };
|
||||
}
|
||||
} catch (error) {
|
||||
if (current === sequence) {
|
||||
errors.value = { ...errors.value, [name]: message(error) };
|
||||
probeStates.value = { ...probeStates.value, [name]: classifyProbeError(error) };
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (current === sequence) loading.value = false;
|
||||
}
|
||||
|
||||
watch(() => props.connectionId, load);
|
||||
onMounted(load);
|
||||
defineExpose({ refresh: () => (void load(), true) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="flex items-center gap-2 text-sm font-medium"><Server class="h-4 w-4" /> {{ t("consul.ui.overview") }}</h2>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7" :disabled="loading" :title="t('consul.ui.refresh')" @click="load"> <Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" /><RefreshCcw v-else class="h-3.5 w-3.5" /> </Button>
|
||||
</div>
|
||||
<div class="grid border-l border-t sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="row in [
|
||||
[t('consul.ui.datacenter'), identity?.datacenter || capabilities?.datacenter || '-'],
|
||||
[t('consul.ui.agentNode'), identity?.node || capabilities?.nodeName || '-'],
|
||||
[t('consul.ui.agentAddress'), identity?.address || '-'],
|
||||
[t('consul.ui.version'), identity?.version || capabilities?.version || '-'],
|
||||
[t('consul.ui.mode'), (identity?.server ?? capabilities?.server) == null ? '-' : (identity?.server ?? capabilities?.server) ? t('consul.ui.server') : t('consul.ui.client')],
|
||||
[t('consul.ui.leader'), leader || '-'],
|
||||
[t('consul.ui.raftPeers'), String(peers.length)],
|
||||
[t('consul.ui.membersAlive'), memberSummary],
|
||||
]"
|
||||
:key="row[0]"
|
||||
class="min-h-16 border-b border-r p-3"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">{{ row[0] }}</div>
|
||||
<div class="mt-1 truncate text-sm font-medium" :title="row[1]">{{ row[1] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid border-l border-t sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="key in probeKeys" :key="key" class="flex min-h-10 items-center justify-between gap-3 border-b border-r px-3 py-2 text-xs">
|
||||
<span class="truncate font-medium">{{ probeLabel(key) }}</span>
|
||||
<span class="shrink-0" :class="probeStates[key] === 'ready' ? 'text-emerald-700 dark:text-emerald-300' : probeStates[key] === 'loading' ? 'text-muted-foreground' : probeStates[key] === 'error' ? 'text-destructive' : 'text-amber-700 dark:text-amber-300'" :title="errors[key] || undefined">
|
||||
{{ probeStateLabel(probeStates[key]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="Object.keys(errors).length" class="mt-4 divide-y border">
|
||||
<div v-for="(error, source) in errors" :key="source" class="flex gap-2 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div>
|
||||
<strong class="mr-1">{{ source }}</strong
|
||||
>{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="autopilotHealth?.fields.length" class="mt-4 overflow-hidden border">
|
||||
<div class="border-b bg-muted/30 px-3 py-2 text-xs font-medium">{{ t("consul.ui.autopilotHealth") }}</div>
|
||||
<dl class="grid sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="field in autopilotHealth.fields" :key="field.name" class="min-w-0 border-b border-r px-3 py-2 text-xs">
|
||||
<dt class="truncate text-muted-foreground">{{ field.name }}</dt>
|
||||
<dd class="mt-1 truncate font-medium" :title="field.value">{{ field.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="mt-4 overflow-hidden border">
|
||||
<div class="border-b bg-muted/30 px-3 py-2 text-xs font-medium">{{ t("consul.ui.members") }}</div>
|
||||
<table class="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-muted-foreground">
|
||||
<th class="px-3 py-2">{{ t("consul.ui.node") }}</th>
|
||||
<th class="px-3 py-2">{{ t("consul.ui.address") }}</th>
|
||||
<th class="w-24 px-3 py-2">{{ t("consul.ui.status") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members" :key="`${member.Name}-${member.Addr}`" class="border-b last:border-0">
|
||||
<td class="truncate px-3 py-2">{{ member.Name }}</td>
|
||||
<td class="truncate px-3 py-2">{{ member.Addr }}:{{ member.Port }}</td>
|
||||
<td class="px-3 py-2">{{ member.Status === 1 ? t("consul.ui.alive") : member.Status === 2 ? t("consul.ui.leaving") : t("consul.ui.failed") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Loader2, Pencil, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulCapabilities, ConsulEnterpriseKind, ConsulEnterpriseWrite, ConsulScopeImpact } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
const active = ref<ConsulEnterpriseKind>("namespace");
|
||||
const items = ref<Record<string, unknown>[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const editorOpen = ref(false);
|
||||
const editorName = ref<string | null>(null);
|
||||
const editorJson = ref("{}");
|
||||
const pendingDelete = ref<string | null>(null);
|
||||
const impact = ref<ConsulScopeImpact | null>(null);
|
||||
const impactLoading = ref(false);
|
||||
let impactSequence = 0;
|
||||
let loadSequence = 0;
|
||||
const status = computed(() => (active.value === "namespace" ? props.capabilities?.namespaces : props.capabilities?.partitions));
|
||||
const canWrite = computed(() => status.value === "supported" && !readOnly.value);
|
||||
const activeLabel = computed(() => (active.value === "namespace" ? t("consul.ui.namespace") : t("consul.ui.adminPartition")));
|
||||
function capabilityLabel(value?: string) {
|
||||
if (value === "supported") return t("consul.ui.capabilitySupported");
|
||||
if (value === "unsupported") return t("consul.ui.capabilityUnsupported");
|
||||
if (value === "disabled") return t("consul.ui.capabilityDisabled");
|
||||
if (value === "forbidden") return t("consul.ui.capabilityForbidden");
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const connectionScope = computed(() => {
|
||||
const config = store.getConfig(props.connectionId);
|
||||
const external = (config?.external_config || {}) as Record<string, unknown>;
|
||||
return { dc: String(external.datacenter || external.consulDatacenter || "default"), partition: String(external.partition || external.consulPartition || "default"), namespace: String(external.namespace || external.consulNamespace || "default") };
|
||||
});
|
||||
async function load() {
|
||||
const sequence = ++loadSequence;
|
||||
const connectionId = props.connectionId;
|
||||
const kind = active.value;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
items.value = [];
|
||||
try {
|
||||
const result = await api.consulEnterpriseList(connectionId, kind);
|
||||
if (sequence !== loadSequence || connectionId !== props.connectionId || kind !== active.value) return;
|
||||
items.value = result.items as Record<string, unknown>[];
|
||||
} catch (cause) {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && kind === active.value) error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (sequence === loadSequence && connectionId === props.connectionId && kind === active.value) loading.value = false;
|
||||
}
|
||||
}
|
||||
function nameOf(item: Record<string, unknown>) {
|
||||
return String(item.Name || "");
|
||||
}
|
||||
function openCreate() {
|
||||
editorName.value = null;
|
||||
editorJson.value = JSON.stringify({ Name: "", ...(active.value === "namespace" ? { Partition: connectionScope.value.partition } : {}), Description: "", Meta: {} }, null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
function openEdit(item: Record<string, unknown>) {
|
||||
editorName.value = nameOf(item);
|
||||
editorJson.value = JSON.stringify(item, null, 2);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
async function save() {
|
||||
try {
|
||||
const item = JSON.parse(editorJson.value) as Record<string, unknown>;
|
||||
const value = { kind: active.value, item } as ConsulEnterpriseWrite;
|
||||
await api.consulEnterpriseApply(props.connectionId, editorName.value, value);
|
||||
editorOpen.value = false;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
async function inspectDelete(name: string) {
|
||||
const current = ++impactSequence;
|
||||
pendingDelete.value = name;
|
||||
impact.value = null;
|
||||
impactLoading.value = true;
|
||||
try {
|
||||
const value = await api.consulEnterpriseImpact(props.connectionId, active.value, name);
|
||||
if (current === impactSequence && pendingDelete.value === name) impact.value = value;
|
||||
} catch (cause) {
|
||||
if (current === impactSequence) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
pendingDelete.value = null;
|
||||
}
|
||||
} finally {
|
||||
if (current === impactSequence) impactLoading.value = false;
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (!pendingDelete.value || !impact.value?.complete) return;
|
||||
try {
|
||||
await api.consulEnterpriseDelete(props.connectionId, active.value, pendingDelete.value);
|
||||
pendingDelete.value = null;
|
||||
impact.value = null;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
function closeDelete() {
|
||||
impactSequence += 1;
|
||||
pendingDelete.value = null;
|
||||
impact.value = null;
|
||||
impactLoading.value = false;
|
||||
}
|
||||
function impactRows(value: ConsulScopeImpact) {
|
||||
return [
|
||||
["services", value.services],
|
||||
["nodes", value.nodes],
|
||||
["kvKeys", value.kvKeys],
|
||||
["healthChecks", value.healthChecks],
|
||||
["sessions", value.sessions],
|
||||
["configEntries", value.configEntries],
|
||||
["intentions", value.intentions],
|
||||
["peerings", value.peerings],
|
||||
["namespaces", value.namespaces],
|
||||
["aclTokens", value.aclTokens],
|
||||
["aclPolicies", value.aclPolicies],
|
||||
["aclRoles", value.aclRoles],
|
||||
["aclAuthMethods", value.aclAuthMethods],
|
||||
["aclBindingRules", value.aclBindingRules],
|
||||
] as const;
|
||||
}
|
||||
watch([active, () => props.connectionId], () => void load());
|
||||
onMounted(() => void load());
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
impactSequence += 1;
|
||||
});
|
||||
defineExpose({
|
||||
refresh: () => {
|
||||
void load();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="border-b px-4 py-2 text-xs text-muted-foreground">
|
||||
{{ t("consul.ui.datacenter") }}: <b>{{ connectionScope.dc }}</b> · {{ t("consul.ui.adminPartition") }}: <b>{{ connectionScope.partition }}</b> · {{ t("consul.ui.namespace") }}: <b>{{ connectionScope.namespace }}</b>
|
||||
</div>
|
||||
<Tabs v-model="active" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex h-11 items-center justify-between border-b px-3">
|
||||
<TabsList class="h-8 p-0.5"
|
||||
><TabsTrigger value="namespace" class="h-7 px-3 text-xs">{{ t("consul.ui.namespaces") }}</TabsTrigger
|
||||
><TabsTrigger value="partition" class="h-7 px-3 text-xs">{{ t("consul.workspace.partitions") }}</TabsTrigger></TabsList
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button><Button v-if="canWrite" size="sm" class="h-7 gap-1 text-xs" @click="openCreate"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.create") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent v-for="kind in ['namespace', 'partition']" :key="kind" :value="kind" class="m-0 min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="status !== 'supported'" class="border px-3 py-2 text-sm text-muted-foreground">{{ t("consul.ui.enterpriseCapability", { status: capabilityLabel(status) }) }}</div>
|
||||
<div v-else-if="loading" class="flex h-28 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<div v-else-if="error" class="border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ error }}</div>
|
||||
<div v-else class="divide-y border">
|
||||
<div v-for="item in items" :key="nameOf(item)" class="flex min-h-12 items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ nameOf(item) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ item.Description || "-" }}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('consul.ui.edit')" @click="openEdit(item)"><Pencil class="h-3.5 w-3.5" /></Button
|
||||
><Button v-if="canWrite" variant="ghost" size="icon" class="h-7 w-7 text-destructive" :title="t('consul.ui.inspectImpactAndDelete')" @click="inspectDelete(nameOf(item))"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="!items.length" class="p-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noResources") }}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Dialog v-model:open="editorOpen"
|
||||
><DialogContent class="max-w-2xl"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ editorName ? t("consul.ui.editResource", { kind: activeLabel }) : t("consul.ui.createResource", { kind: activeLabel }) }}</DialogTitle></DialogHeader
|
||||
><textarea v-model="editorJson" class="h-72 w-full resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none" spellcheck="false" /><DialogFooter
|
||||
><Button variant="outline" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button v-if="canWrite" @click="save">{{ t("consul.ui.save") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
<Dialog
|
||||
:open="Boolean(pendingDelete)"
|
||||
@update:open="
|
||||
(value) => {
|
||||
if (!value) closeDelete();
|
||||
}
|
||||
"
|
||||
><DialogContent
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("consul.ui.deleteResource", { label: pendingDelete }) }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div v-if="impactLoading" class="flex min-h-24 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<div v-else-if="impact" class="text-sm">
|
||||
<dl class="grid grid-cols-2 border-l border-t text-xs">
|
||||
<div v-for="[resource, count] in impactRows(impact)" :key="resource" class="flex items-center justify-between gap-3 border-b border-r px-3 py-2">
|
||||
<dt class="truncate text-muted-foreground">{{ t(`consul.ui.impactResource.${resource}`) }}</dt>
|
||||
<dd class="font-mono font-medium">{{ count }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-if="impact.filteredByAcls" class="mt-2 text-destructive">{{ t("consul.ui.impactAclFiltered") }}</p>
|
||||
<p v-if="impact.unavailableResources.length" class="mt-2 break-words text-destructive">{{ t("consul.ui.impactUnavailable", { resources: impact.unavailableResources.join(", ") }) }}</p>
|
||||
<p v-if="!impact.complete" class="mt-2 text-destructive">{{ t("consul.ui.impactBlocked") }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" @click="closeDelete">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="impactLoading || !impact?.complete" @click="remove">{{ t("consul.ui.delete") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,941 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { CircleCheck, Loader2, LockKeyhole, Pause, Plus, Radio, RefreshCcw, Search, ServerCog, Trash2, Wrench } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import ConsulListPagination from "@/components/consul/ConsulListPagination.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { nextWatchIndex } from "@/lib/consul/watchState";
|
||||
import { consulAgentWriteTargetSafe } from "@/lib/consul/agentTarget";
|
||||
import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/lib/consul/pagination";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulAgentIdentity, ConsulAgentService, ConsulAgentServiceRegistration, ConsulCatalogNode, ConsulCatalogServiceNode, ConsulDomainWatchResponse, ConsulHealthCheck, ConsulNodeServices, ConsulScope } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const services = ref<Record<string, string[]>>({});
|
||||
const nodes = ref<ConsulCatalogNode[]>([]);
|
||||
const localServices = ref<Record<string, ConsulAgentService>>({});
|
||||
const localChecks = ref<Record<string, ConsulHealthCheck>>({});
|
||||
const selectedLocalService = ref<ConsulAgentService | null>(null);
|
||||
const identity = ref<ConsulAgentIdentity | null>(null);
|
||||
const agentTarget = computed(() => {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
const raw = config.agentTarget || config.agent_target;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const target = raw as Record<string, unknown>;
|
||||
const node = String(target.node || "").trim();
|
||||
const address = String(target.address || "").trim();
|
||||
return node && address ? { node, address } : null;
|
||||
});
|
||||
const canAgentWrite = computed(() => consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node));
|
||||
const agentTargetStatus = computed<"verifying" | "writable" | "readonly">(() => {
|
||||
if (agentLoading.value && !identity.value) return "verifying";
|
||||
return canAgentWrite.value ? "writable" : "readonly";
|
||||
});
|
||||
const selected = ref("");
|
||||
const selectedNode = ref("");
|
||||
const browseMode = ref<"service" | "node">("service");
|
||||
const browseFilter = ref("");
|
||||
const localServiceFilter = ref("");
|
||||
const instances = ref<ConsulCatalogServiceNode[]>([]);
|
||||
const selectedCatalogInstanceKey = ref("");
|
||||
const nodeServices = ref<ConsulNodeServices | null>(null);
|
||||
const selectedNodeServiceId = ref("");
|
||||
const browsePage = ref(1);
|
||||
const catalogInstancePage = ref(1);
|
||||
const nodeServicePage = ref(1);
|
||||
const localServicePage = ref(1);
|
||||
const errors = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const agentLoading = ref(true);
|
||||
const saving = ref(false);
|
||||
const editorOpen = ref(false);
|
||||
const maintenancePendingId = ref("");
|
||||
const form = ref({ id: "", name: "", tags: "", address: "", port: 0 });
|
||||
let sequence = 0;
|
||||
const watchEnabled = ref(false);
|
||||
const watchPaused = ref(false);
|
||||
const watchIndex = ref<string | null>(null);
|
||||
let watchConnectionId = "";
|
||||
let watchScope: ConsulScope = { datacenter: "", namespace: "", partition: "" };
|
||||
let watchGeneration = 0;
|
||||
let servicesWatchId = "";
|
||||
let instancesWatchId = "";
|
||||
let nodesWatchId = "";
|
||||
let nodeServicesWatchId = "";
|
||||
|
||||
const serviceNames = computed(() => Object.keys(services.value).sort());
|
||||
const nodeNames = computed(() =>
|
||||
nodes.value
|
||||
.map((node) => node.Node)
|
||||
.filter(Boolean)
|
||||
.sort(),
|
||||
);
|
||||
const normalizedBrowseFilter = computed(() => browseFilter.value.trim().toLocaleLowerCase());
|
||||
const filteredServiceNames = computed(() =>
|
||||
serviceNames.value.filter((name) => {
|
||||
const query = normalizedBrowseFilter.value;
|
||||
return !query || `${name} ${services.value[name]?.join(" ") || ""}`.toLocaleLowerCase().includes(query);
|
||||
}),
|
||||
);
|
||||
const filteredNodeNames = computed(() => nodeNames.value.filter((name) => !normalizedBrowseFilter.value || name.toLocaleLowerCase().includes(normalizedBrowseFilter.value)));
|
||||
const localServiceItems = computed(() => Object.values(localServices.value).sort((left, right) => left.Service.localeCompare(right.Service) || left.ID.localeCompare(right.ID)));
|
||||
const normalizedLocalServiceFilter = computed(() => localServiceFilter.value.trim().toLocaleLowerCase());
|
||||
const filteredLocalServiceItems = computed(() =>
|
||||
localServiceItems.value.filter((service) => {
|
||||
const query = normalizedLocalServiceFilter.value;
|
||||
if (!query) return true;
|
||||
const metadata = Object.entries(service.Meta || {})
|
||||
.map(([key, value]) => `${key} ${value}`)
|
||||
.join(" ");
|
||||
return `${service.Service} ${service.ID} ${service.Address} ${service.Port} ${service.Tags.join(" ")} ${metadata}`.toLocaleLowerCase().includes(query);
|
||||
}),
|
||||
);
|
||||
const nodeServiceItems = computed(() => Object.values(nodeServices.value?.Services || {}).sort((left, right) => left.Service.localeCompare(right.Service) || left.ID.localeCompare(right.ID)));
|
||||
const pagedServiceNames = computed(() => paginateConsulItems(filteredServiceNames.value, browsePage.value));
|
||||
const pagedNodeNames = computed(() => paginateConsulItems(filteredNodeNames.value, browsePage.value));
|
||||
const pagedCatalogInstances = computed(() => paginateConsulItems(instances.value, catalogInstancePage.value));
|
||||
const pagedNodeServices = computed(() => paginateConsulItems(nodeServiceItems.value, nodeServicePage.value));
|
||||
const pagedLocalServices = computed(() => paginateConsulItems(filteredLocalServiceItems.value, localServicePage.value));
|
||||
const maintenanceServiceIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
Object.values(localChecks.value)
|
||||
.filter((check) => check.Type === "maintenance" || check.CheckID.startsWith("_service_maintenance:"))
|
||||
.map((check) => check.ServiceID || check.CheckID.slice("_service_maintenance:".length)),
|
||||
),
|
||||
);
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function normalizeCatalogServices(value: Record<string, string[]>): Record<string, string[]> {
|
||||
return Object.fromEntries(Object.entries(value).map(([name, tags]) => [name, [...tags].sort((left, right) => left.localeCompare(right))]));
|
||||
}
|
||||
|
||||
function isServiceInMaintenance(id: string) {
|
||||
return maintenanceServiceIds.value.has(id);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, milliseconds: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function currentScope(): ConsulScope {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
return {
|
||||
datacenter: String(config.datacenter || config.consulDatacenter || config.consul_datacenter || ""),
|
||||
namespace: String(config.namespace || config.consulNamespace || config.consul_namespace || ""),
|
||||
partition: String(config.partition || config.consulPartition || config.consul_partition || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function watchId(kind: string) {
|
||||
return globalThis.crypto?.randomUUID?.() || `${kind}-${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
function watchActive(operationId: string) {
|
||||
return watchEnabled.value && !watchPaused.value && watchConnectionId === props.connectionId && watchGeneration === consulStore.generation && operationId !== "";
|
||||
}
|
||||
|
||||
async function stopWatches() {
|
||||
const operations = [servicesWatchId, instancesWatchId, nodesWatchId, nodeServicesWatchId].filter(Boolean);
|
||||
servicesWatchId = "";
|
||||
instancesWatchId = "";
|
||||
nodesWatchId = "";
|
||||
nodeServicesWatchId = "";
|
||||
await Promise.all(
|
||||
operations.map(async (operationId) => {
|
||||
consulStore.completeOperation(operationId);
|
||||
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, operationId).catch(() => false);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runServicesWatch(operationId: string) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && servicesWatchId === operationId) {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: { kind: "catalogServices" },
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<Record<string, string[]>>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) {
|
||||
services.value = normalizeCatalogServices(result.items);
|
||||
if (!selected.value || !services.value[selected.value]) {
|
||||
selected.value = serviceNames.value[0] || "";
|
||||
await loadInstances();
|
||||
await startInstanceWatch();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
|
||||
async function runInstancesWatch(operationId: string, service: string) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && instancesWatchId === operationId && selected.value === service) {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: { kind: "catalogServiceNodes", service },
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<ConsulCatalogServiceNode[]>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) instances.value = result.items;
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
|
||||
async function runNodesWatch(operationId: string) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && nodesWatchId === operationId && browseMode.value === "node") {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: { kind: "catalogNodes" },
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<ConsulCatalogNode[]>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) {
|
||||
nodes.value = result.items;
|
||||
if (!selectedNode.value || !nodeNames.value.includes(selectedNode.value)) {
|
||||
selectedNode.value = nodeNames.value[0] || "";
|
||||
await loadNodeServices();
|
||||
await startNodeServicesWatch();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
|
||||
async function runNodeServicesWatch(operationId: string, node: string) {
|
||||
let index: string | null = null;
|
||||
while (watchActive(operationId) && nodeServicesWatchId === operationId && browseMode.value === "node" && selectedNode.value === node) {
|
||||
try {
|
||||
const result = (await api.consulDomainWatch(watchConnectionId, {
|
||||
operationId,
|
||||
generation: watchGeneration,
|
||||
target: { kind: "catalogNodeServices", node },
|
||||
index,
|
||||
waitSeconds: 300,
|
||||
})) as ConsulDomainWatchResponse<ConsulNodeServices>;
|
||||
index = nextWatchIndex(index, result.metadata.index).index;
|
||||
watchIndex.value = index;
|
||||
if (result.changed || result.indexReset) nodeServices.value = result.items;
|
||||
} catch (error) {
|
||||
if (watchActive(operationId) && !String(error).includes("CANCELLED")) errors.value = { ...errors.value, [t("consul.ui.watch")]: errorMessage(error) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
consulStore.completeOperation(operationId);
|
||||
}
|
||||
|
||||
async function startInstanceWatch() {
|
||||
if (!watchEnabled.value || watchPaused.value || !selected.value) return;
|
||||
if (instancesWatchId) await stopInstanceWatch();
|
||||
const operationId = watchId("consul-catalog-instances");
|
||||
instancesWatchId = operationId;
|
||||
consulStore.registerOperation(operationId);
|
||||
void runInstancesWatch(operationId, selected.value);
|
||||
}
|
||||
|
||||
async function stopInstanceWatch() {
|
||||
const operationId = instancesWatchId;
|
||||
instancesWatchId = "";
|
||||
if (!operationId) return;
|
||||
consulStore.completeOperation(operationId);
|
||||
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, operationId).catch(() => false);
|
||||
}
|
||||
|
||||
async function startNodeServicesWatch() {
|
||||
if (!watchEnabled.value || watchPaused.value || !selectedNode.value || browseMode.value !== "node") return;
|
||||
if (nodeServicesWatchId) {
|
||||
const previousOperationId = nodeServicesWatchId;
|
||||
nodeServicesWatchId = "";
|
||||
consulStore.completeOperation(previousOperationId);
|
||||
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, previousOperationId).catch(() => false);
|
||||
}
|
||||
const operationId = watchId("consul-catalog-node-services");
|
||||
nodeServicesWatchId = operationId;
|
||||
consulStore.registerOperation(operationId);
|
||||
void runNodeServicesWatch(operationId, selectedNode.value);
|
||||
}
|
||||
|
||||
async function startWatches() {
|
||||
if ((servicesWatchId || nodesWatchId) && watchEnabled.value && !watchPaused.value) return;
|
||||
watchEnabled.value = true;
|
||||
watchPaused.value = false;
|
||||
watchConnectionId = props.connectionId;
|
||||
watchScope = currentScope();
|
||||
consulStore.bindConnection(watchConnectionId, watchScope);
|
||||
watchGeneration = consulStore.generation;
|
||||
if (browseMode.value === "service") {
|
||||
servicesWatchId = watchId("consul-catalog-services");
|
||||
consulStore.registerOperation(servicesWatchId);
|
||||
void runServicesWatch(servicesWatchId);
|
||||
await startInstanceWatch();
|
||||
} else {
|
||||
nodesWatchId = watchId("consul-catalog-nodes");
|
||||
consulStore.registerOperation(nodesWatchId);
|
||||
void runNodesWatch(nodesWatchId);
|
||||
await startNodeServicesWatch();
|
||||
}
|
||||
}
|
||||
|
||||
async function pauseWatches() {
|
||||
watchPaused.value = true;
|
||||
await stopWatches();
|
||||
}
|
||||
|
||||
async function independent(name: string, action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
errors.value = { ...errors.value, [name]: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
agentLoading.value = true;
|
||||
errors.value = {};
|
||||
// Catalog browsing must remain responsive even when an Agent endpoint is slow or unavailable.
|
||||
// The local-Agent section is supplementary, while Catalog is the primary navigation surface.
|
||||
await Promise.all([
|
||||
independent(t("consul.ui.catalogServices"), async () => {
|
||||
const result = await api.consulCatalogServices(props.connectionId);
|
||||
if (current === sequence) services.value = normalizeCatalogServices(result.items);
|
||||
}),
|
||||
independent(t("consul.ui.catalogNodes"), async () => {
|
||||
const result = await api.consulCatalogNodes(props.connectionId);
|
||||
if (current === sequence) nodes.value = result.items;
|
||||
}),
|
||||
]);
|
||||
if (current === sequence) {
|
||||
if (!selected.value || !services.value[selected.value]) selected.value = serviceNames.value[0] || "";
|
||||
if (!selectedNode.value || !nodeNames.value.includes(selectedNode.value)) selectedNode.value = nodeNames.value[0] || "";
|
||||
loading.value = false;
|
||||
if (browseMode.value === "service") await loadInstances();
|
||||
else await loadNodeServices();
|
||||
}
|
||||
void loadAgentData(current);
|
||||
}
|
||||
|
||||
async function loadAgentData(current = sequence) {
|
||||
agentLoading.value = true;
|
||||
await Promise.all([
|
||||
independent(t("consul.ui.agent"), async () => {
|
||||
const result = await withTimeout(api.consulAgentSelf(props.connectionId), 8_000, t("consul.ui.agentIdentityRequestTimedOut"));
|
||||
if (current === sequence) identity.value = result;
|
||||
}),
|
||||
independent(t("consul.ui.localAgentServices"), async () => {
|
||||
const result = await withTimeout(api.consulAgentServices(props.connectionId), 8_000, t("consul.ui.localServicesRequestTimedOut"));
|
||||
if (current === sequence) localServices.value = result;
|
||||
}),
|
||||
independent(t("consul.ui.localAgentChecks"), async () => {
|
||||
const result = await withTimeout(api.consulAgentChecks(props.connectionId), 8_000, t("consul.ui.localChecksRequestTimedOut"));
|
||||
if (current === sequence) localChecks.value = result;
|
||||
}),
|
||||
]);
|
||||
if (current === sequence) agentLoading.value = false;
|
||||
}
|
||||
|
||||
async function selectService(service: string) {
|
||||
selected.value = service;
|
||||
await loadInstances();
|
||||
await startInstanceWatch();
|
||||
}
|
||||
|
||||
async function selectNode(node: string) {
|
||||
selectedNode.value = node;
|
||||
await loadNodeServices();
|
||||
await startNodeServicesWatch();
|
||||
}
|
||||
|
||||
async function loadInstances() {
|
||||
instances.value = [];
|
||||
catalogInstancePage.value = 1;
|
||||
selectedCatalogInstanceKey.value = "";
|
||||
const service = selected.value;
|
||||
if (!service) return;
|
||||
await independent(t("consul.ui.serviceInstances"), async () => {
|
||||
const result = await api.consulCatalogServiceNodes(props.connectionId, service);
|
||||
if (selected.value === service) instances.value = result.items;
|
||||
});
|
||||
}
|
||||
|
||||
function catalogInstanceKey(item: ConsulCatalogServiceNode) {
|
||||
return `${item.Node}\u0000${item.ServiceID}`;
|
||||
}
|
||||
|
||||
function toggleCatalogInstance(item: ConsulCatalogServiceNode) {
|
||||
const key = catalogInstanceKey(item);
|
||||
selectedCatalogInstanceKey.value = selectedCatalogInstanceKey.value === key ? "" : key;
|
||||
}
|
||||
|
||||
async function loadNodeServices() {
|
||||
nodeServices.value = null;
|
||||
nodeServicePage.value = 1;
|
||||
selectedNodeServiceId.value = "";
|
||||
const node = selectedNode.value;
|
||||
if (!node) return;
|
||||
await independent(t("consul.ui.nodeServices"), async () => {
|
||||
const result = await api.consulCatalogNodeServices(props.connectionId, node);
|
||||
if (selectedNode.value === node) nodeServices.value = result.items;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNodeService(id: string) {
|
||||
selectedNodeServiceId.value = selectedNodeServiceId.value === id ? "" : id;
|
||||
}
|
||||
|
||||
function updateCatalogInstancePage(page: number) {
|
||||
catalogInstancePage.value = page;
|
||||
selectedCatalogInstanceKey.value = "";
|
||||
}
|
||||
|
||||
function updateNodeServicePage(page: number) {
|
||||
nodeServicePage.value = page;
|
||||
selectedNodeServiceId.value = "";
|
||||
}
|
||||
|
||||
function updateLocalServicePage(page: number) {
|
||||
localServicePage.value = page;
|
||||
selectedLocalService.value = null;
|
||||
}
|
||||
|
||||
async function switchBrowseMode(mode: "service" | "node") {
|
||||
if (browseMode.value === mode) return;
|
||||
const resume = watchEnabled.value && !watchPaused.value;
|
||||
await stopWatches();
|
||||
browseMode.value = mode;
|
||||
if (mode === "service") await loadInstances();
|
||||
else await loadNodeServices();
|
||||
if (resume) await startWatches();
|
||||
}
|
||||
|
||||
async function openLocalService(id: string) {
|
||||
if (selectedLocalService.value?.ID === id) {
|
||||
selectedLocalService.value = null;
|
||||
return;
|
||||
}
|
||||
await independent(t("consul.ui.localServiceDetails"), async () => {
|
||||
selectedLocalService.value = await api.consulAgentService(props.connectionId, id);
|
||||
});
|
||||
}
|
||||
|
||||
async function registerService() {
|
||||
if (!canAgentWrite.value || !form.value.name.trim()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const registration: ConsulAgentServiceRegistration = {
|
||||
id: form.value.id.trim(),
|
||||
name: form.value.name.trim(),
|
||||
tags: form.value.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
address: form.value.address.trim(),
|
||||
port: Number(form.value.port) || 0,
|
||||
meta: {},
|
||||
weights: { Passing: 1, Warning: 1 },
|
||||
kind: "",
|
||||
enableTagOverride: false,
|
||||
proxy: null,
|
||||
checks: [],
|
||||
};
|
||||
await api.consulAgentRegisterService(props.connectionId, registration);
|
||||
editorOpen.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
errors.value = { ...errors.value, [t("consul.ui.writeOperation")]: errorMessage(error) };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deregister(id: string) {
|
||||
if (!canAgentWrite.value || !window.confirm(t("consul.ui.deregisterService", { id, node: agentTarget.value?.node || t("consul.ui.targetAgent") }))) return;
|
||||
await independent(t("consul.ui.writeOperation"), async () => {
|
||||
await api.consulAgentDeregisterService(props.connectionId, id);
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function maintenance(id: string, enable: boolean) {
|
||||
if (!canAgentWrite.value) return;
|
||||
maintenancePendingId.value = id;
|
||||
try {
|
||||
await independent(t("consul.ui.writeOperation"), async () => {
|
||||
await api.consulAgentServiceMaintenance(props.connectionId, id, enable, t("consul.ui.maintenanceReason"));
|
||||
await loadAgentData();
|
||||
});
|
||||
} finally {
|
||||
maintenancePendingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
watch([browseFilter, browseMode], () => {
|
||||
browsePage.value = 1;
|
||||
});
|
||||
watch(localServiceFilter, () => {
|
||||
localServicePage.value = 1;
|
||||
selectedLocalService.value = null;
|
||||
});
|
||||
watch(filteredServiceNames, (items) => {
|
||||
if (browseMode.value === "service") browsePage.value = clampConsulPage(browsePage.value, items.length);
|
||||
});
|
||||
watch(filteredNodeNames, (items) => {
|
||||
if (browseMode.value === "node") browsePage.value = clampConsulPage(browsePage.value, items.length);
|
||||
});
|
||||
watch(instances, (items) => {
|
||||
catalogInstancePage.value = clampConsulPage(catalogInstancePage.value, items.length);
|
||||
});
|
||||
watch(nodeServiceItems, (items) => {
|
||||
nodeServicePage.value = clampConsulPage(nodeServicePage.value, items.length);
|
||||
});
|
||||
watch(filteredLocalServiceItems, (items) => {
|
||||
localServicePage.value = clampConsulPage(localServicePage.value, items.length);
|
||||
});
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
async () => {
|
||||
await stopWatches();
|
||||
watchEnabled.value = false;
|
||||
watchPaused.value = false;
|
||||
watchIndex.value = null;
|
||||
identity.value = null;
|
||||
agentLoading.value = true;
|
||||
browsePage.value = 1;
|
||||
catalogInstancePage.value = 1;
|
||||
nodeServicePage.value = 1;
|
||||
localServicePage.value = 1;
|
||||
await load();
|
||||
},
|
||||
);
|
||||
onMounted(load);
|
||||
onDeactivated(() => {
|
||||
watchPaused.value = true;
|
||||
void stopWatches();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
void stopWatches();
|
||||
});
|
||||
defineExpose({ refresh: () => (void load(), true) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0">
|
||||
<aside class="flex w-64 shrink-0 flex-col border-r bg-muted/10">
|
||||
<div class="shrink-0 border-b bg-background">
|
||||
<div class="flex h-11 items-center justify-between px-3">
|
||||
<div class="text-sm font-medium">{{ browseMode === "service" ? t("consul.ui.catalogServices") : t("consul.ui.catalogNodes") }}</div>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7" :disabled="loading" :title="t('consul.ui.refresh')" @click="load"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 px-3 pb-2 text-xs">
|
||||
<Button size="sm" class="h-7 rounded-r-none" :variant="browseMode === 'service' ? 'default' : 'outline'" @click="switchBrowseMode('service')">{{ t("consul.ui.service") }}</Button>
|
||||
<Button size="sm" class="h-7 rounded-l-none" :variant="browseMode === 'node' ? 'default' : 'outline'" @click="switchBrowseMode('node')">{{ t("consul.ui.node") }}</Button>
|
||||
</div>
|
||||
<div class="relative px-3 pb-3">
|
||||
<Search class="pointer-events-none absolute left-5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="browseFilter" class="h-8 pl-7 text-xs" :placeholder="browseMode === 'service' ? t('consul.ui.searchAllServices') : t('consul.ui.searchAllNodes')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto py-1">
|
||||
<template v-if="browseMode === 'service'">
|
||||
<Button v-for="name in pagedServiceNames" :key="name" type="button" size="sm" :variant="selected === name ? 'secondary' : 'ghost'" class="h-auto min-h-11 w-full justify-start rounded-none px-3 py-2 text-left text-xs" @click="selectService(name)">
|
||||
<span class="min-w-0 flex-1 truncate font-medium" :title="name">{{ name }}</span>
|
||||
</Button>
|
||||
<div v-if="!filteredServiceNames.length && !loading" class="p-3 text-xs text-muted-foreground">{{ serviceNames.length ? t("consul.ui.noMatchingServices") : t("consul.ui.noVisibleServices") }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button v-for="name in pagedNodeNames" :key="name" type="button" size="sm" :variant="selectedNode === name ? 'secondary' : 'ghost'" class="h-auto min-h-11 w-full justify-start rounded-none px-3 py-2 text-left text-xs" @click="selectNode(name)"
|
||||
><span class="truncate font-medium">{{ name }}</span></Button
|
||||
>
|
||||
<div v-if="!filteredNodeNames.length && !loading" class="p-3 text-xs text-muted-foreground">{{ nodeNames.length ? t("consul.ui.noMatchingNodes") : t("consul.ui.noVisibleNodes") }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="(browseMode === 'service' ? filteredServiceNames.length : filteredNodeNames.length) > CONSUL_LIST_PAGE_SIZE" class="shrink-0 border-t bg-background px-2 py-1">
|
||||
<ConsulListPagination compact :total="browseMode === 'service' ? filteredServiceNames.length : filteredNodeNames.length" :page="browsePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="browsePage = $event" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 flex-1 overflow-auto p-4">
|
||||
<header class="mb-4 flex flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-muted-foreground">{{ browseMode === "service" ? t("consul.ui.catalogServices") : t("consul.ui.catalogNodes") }}</div>
|
||||
<h2 class="mt-0.5 truncate text-lg font-semibold">{{ browseMode === "service" ? selected || t("consul.ui.services") : selectedNode || t("consul.ui.nodes") }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge :variant="agentTargetStatus === 'writable' ? 'secondary' : 'outline'" :class="agentTargetStatus === 'writable' ? 'text-emerald-700 dark:text-emerald-300' : agentTargetStatus === 'readonly' ? 'border-amber-500/40 text-amber-700 dark:text-amber-300' : 'text-muted-foreground'">
|
||||
<Loader2 v-if="agentTargetStatus === 'verifying'" class="mr-1 h-3 w-3 animate-spin" /><ServerCog v-else class="mr-1 h-3 w-3" />{{
|
||||
agentTargetStatus === "verifying" ? t("consul.ui.agentTargetVerifying") : t("consul.ui.agentTarget", { target: canAgentWrite && agentTarget ? `${agentTarget.node} (${agentTarget.address})` : t("consul.ui.unknownWritesDisabled") })
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span v-if="watchEnabled" class="hidden text-xs text-muted-foreground lg:inline">{{ watchPaused ? t("consul.ui.watchPaused") : t("consul.ui.watchingIndex", { index: watchIndex || "1" }) }}</span>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :title="watchEnabled && !watchPaused ? t('consul.ui.pauseWatch') : t('consul.ui.startWatch')" @click="watchEnabled && !watchPaused ? pauseWatches() : startWatches()"
|
||||
><Pause v-if="watchEnabled && !watchPaused" class="h-3.5 w-3.5" /><Radio v-else class="h-3.5 w-3.5" />{{ watchEnabled && !watchPaused ? t("consul.ui.pauseWatch") : t("consul.ui.startWatch") }}</Button
|
||||
>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="!canAgentWrite" :title="canAgentWrite ? undefined : t('consul.ui.unknownWritesDisabled')" @click="editorOpen = !editorOpen"><Plus class="h-3.5 w-3.5" />{{ t("consul.ui.register") }}</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="Object.keys(errors).length" class="mb-3 rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div v-for="(error, name) in errors" :key="name">
|
||||
<strong>{{ name }}:</strong> {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="editorOpen" class="mb-4 rounded-lg border bg-muted/10 p-4 sm:p-5">
|
||||
<p class="mb-4 text-xs leading-5 text-amber-700 dark:text-amber-300">{{ t("consul.ui.registrationHint") }}</p>
|
||||
<div class="grid gap-x-5 gap-y-4 md:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.name") }}</Label
|
||||
><Input v-model="form.name" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.id") }}</Label
|
||||
><Input v-model="form.id" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.address") }}</Label
|
||||
><Input v-model="form.address" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.port") }}</Label
|
||||
><Input v-model.number="form.port" class="h-9" type="number" min="0" max="65535" />
|
||||
</div>
|
||||
<div class="space-y-1.5 md:col-span-2">
|
||||
<Label class="block text-xs font-medium">{{ t("consul.ui.tags") }}</Label
|
||||
><Input v-model="form.tags" class="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" size="sm" @click="editorOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button size="sm" :disabled="saving || !canAgentWrite || !form.name.trim()" @click="registerService"><Loader2 v-if="saving" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("consul.ui.registerOn", { node: agentTarget?.node || t("consul.ui.targetAgent") }) }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between gap-3 border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ browseMode === "service" ? t("consul.ui.catalogInstances") : t("consul.ui.nodeServices") }}</span
|
||||
><Badge variant="secondary">{{ browseMode === "service" ? instances.length : nodeServiceItems.length }}</Badge>
|
||||
</div>
|
||||
<ConsulListPagination v-if="browseMode === 'service'" :total="instances.length" :page="catalogInstancePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="updateCatalogInstancePage" />
|
||||
<ConsulListPagination v-else :total="nodeServiceItems.length" :page="nodeServicePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="updateNodeServicePage" />
|
||||
</div>
|
||||
<div v-if="browseMode === 'service'" class="overflow-x-auto">
|
||||
<table class="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/10 text-left text-muted-foreground">
|
||||
<th class="w-[24%] px-3 py-2.5 font-medium">{{ t("consul.ui.node") }}</th>
|
||||
<th class="w-[17%] px-3 py-2.5 font-medium">{{ t("consul.ui.serviceId") }}</th>
|
||||
<th class="w-[18%] px-3 py-2.5 font-medium">{{ t("consul.ui.address") }}</th>
|
||||
<th class="w-[18%] px-3 py-2.5 font-medium">{{ t("consul.ui.tags") }}</th>
|
||||
<th class="w-[23%] px-3 py-2.5 font-medium">{{ t("consul.ui.metadata") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in pagedCatalogInstances" :key="catalogInstanceKey(item)">
|
||||
<tr
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="cursor-pointer border-b outline-none hover:bg-muted/20 focus-visible:bg-muted/30"
|
||||
:class="selectedCatalogInstanceKey === catalogInstanceKey(item) && 'bg-muted/20'"
|
||||
:aria-expanded="selectedCatalogInstanceKey === catalogInstanceKey(item)"
|
||||
@click="toggleCatalogInstance(item)"
|
||||
@keydown.enter.prevent="toggleCatalogInstance(item)"
|
||||
@keydown.space.prevent="toggleCatalogInstance(item)"
|
||||
>
|
||||
<td class="px-3 py-3">
|
||||
<div class="truncate font-medium">{{ item.Node }}</div>
|
||||
<div
|
||||
class="mt-0.5 truncate text-muted-foreground"
|
||||
:title="
|
||||
Object.entries(item.NodeMeta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')
|
||||
"
|
||||
>
|
||||
{{ item.Datacenter }} · {{ Object.keys(item.NodeMeta).length }} {{ t("consul.ui.metadata") }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 font-mono text-muted-foreground">
|
||||
<span class="block truncate" :title="item.ServiceID">{{ item.ServiceID || "-" }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 font-mono">
|
||||
<span class="block truncate">{{ item.ServiceAddress || item.Address }}:{{ item.ServicePort }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div v-if="item.ServiceTags.length" class="flex flex-wrap gap-1">
|
||||
<Badge v-for="tag in item.ServiceTags" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="block truncate text-muted-foreground"
|
||||
:title="
|
||||
Object.entries(item.ServiceMeta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')
|
||||
"
|
||||
>{{
|
||||
Object.entries(item.ServiceMeta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ") || "-"
|
||||
}}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="selectedCatalogInstanceKey === catalogInstanceKey(item)" class="border-b bg-muted/10">
|
||||
<td colspan="5" class="px-3 py-3">
|
||||
<div class="grid overflow-hidden rounded-md border bg-background sm:grid-cols-4">
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceKind") }}</div>
|
||||
<div class="mt-1 font-medium">{{ item.ServiceKind || t("consul.ui.standard") }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.indexes") }}</div>
|
||||
<div class="mt-1 font-mono">{{ item.CreateIndex }} / {{ item.ModifyIndex }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceWeights") }}</div>
|
||||
<div class="mt-1 font-mono">{{ item.ServiceWeights.Passing }} / {{ item.ServiceWeights.Warning }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.nodeId") }}</div>
|
||||
<div class="mt-1 truncate font-mono" :title="item.ID">{{ item.ID || "-" }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:col-span-4">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.nodeMeta") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<code v-for="[key, value] in Object.entries(item.NodeMeta)" :key="key" class="rounded bg-muted px-1.5 py-0.5">{{ key }}={{ value }}</code
|
||||
><span v-if="!Object.keys(item.NodeMeta).length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 py-2 sm:col-span-4">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceMeta") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<code v-for="[key, value] in Object.entries(item.ServiceMeta)" :key="key" class="rounded bg-muted px-1.5 py-0.5">{{ key }}={{ value }}</code
|
||||
><span v-if="!Object.keys(item.ServiceMeta).length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!instances.length && !loading">
|
||||
<td colspan="5" class="px-3 py-8 text-center text-muted-foreground">{{ t("consul.ui.noVisibleServices") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="p-3 text-xs">
|
||||
<div class="mb-3 flex flex-wrap gap-2 text-muted-foreground">
|
||||
<Badge variant="outline">{{ nodeServices?.Node.Address || "-" }}</Badge
|
||||
><Badge variant="outline">{{ nodeServices?.Node.Datacenter || "-" }}</Badge>
|
||||
</div>
|
||||
<div
|
||||
class="mb-3 rounded-md bg-muted/30 px-2.5 py-2 text-muted-foreground"
|
||||
:title="
|
||||
Object.entries(nodeServices?.Node.NodeMeta || {})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')
|
||||
"
|
||||
>
|
||||
{{ t("consul.ui.nodeMeta") }} ·
|
||||
{{
|
||||
Object.entries(nodeServices?.Node.NodeMeta || {})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ") || "-"
|
||||
}}
|
||||
</div>
|
||||
<div v-for="service in pagedNodeServices" :key="service.ID" class="border-t first:border-t-0">
|
||||
<button type="button" class="w-full px-1 py-2.5 text-left outline-none hover:bg-muted/20 focus-visible:bg-muted/30" :class="selectedNodeServiceId === service.ID && 'bg-muted/20'" :aria-expanded="selectedNodeServiceId === service.ID" @click="toggleNodeService(service.ID)">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
|
||||
><span class="font-medium">{{ service.Service }}</span
|
||||
><code class="text-muted-foreground">{{ service.ID }}</code
|
||||
><span class="font-mono text-muted-foreground">{{ service.Address || nodeServices?.Node.Address }}:{{ service.Port }}</span></span
|
||||
>
|
||||
<span
|
||||
class="mt-1 block truncate text-muted-foreground"
|
||||
:title="
|
||||
Object.entries(service.Meta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')
|
||||
"
|
||||
>{{ t("consul.ui.serviceMeta") }} ·
|
||||
{{
|
||||
Object.entries(service.Meta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ") || "-"
|
||||
}}</span
|
||||
>
|
||||
</button>
|
||||
<div v-if="selectedNodeServiceId === service.ID" class="pb-3">
|
||||
<div class="grid overflow-hidden rounded-md border bg-background sm:grid-cols-2">
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceWeights") }}</div>
|
||||
<div class="mt-1 font-mono">{{ service.Weights.Passing }} / {{ service.Weights.Warning }}</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.tags") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Badge v-for="tag in service.Tags" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge
|
||||
><span v-if="!service.Tags.length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:col-span-2">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.serviceMeta") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<code v-for="[key, value] in Object.entries(service.Meta)" :key="key" class="rounded bg-muted px-1.5 py-0.5">{{ key }}={{ value }}</code
|
||||
><span v-if="!Object.keys(service.Meta).length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 py-2 sm:col-span-2">
|
||||
<div class="text-muted-foreground">{{ t("consul.ui.taggedAddresses") }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<code v-for="[key, value] in Object.entries(service.TaggedAddresses)" :key="key" class="rounded bg-muted px-1.5 py-0.5">{{ key }}={{ value.Address }}:{{ value.Port }}</code
|
||||
><span v-if="!Object.keys(service.TaggedAddresses).length">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!Object.keys(nodeServices?.Services || {}).length && !loading" class="py-6 text-center text-muted-foreground">{{ t("consul.ui.noVisibleServices") }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-lg border">
|
||||
<div class="flex flex-wrap items-center gap-3 border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex shrink-0 items-center gap-2 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.localAgentServices") }}</span
|
||||
><Badge variant="secondary">{{ localServiceFilter.trim() ? `${filteredLocalServiceItems.length} / ${localServiceItems.length}` : localServiceItems.length }}</Badge>
|
||||
</div>
|
||||
<div class="relative min-w-52 flex-1 sm:max-w-md">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="localServiceFilter" class="h-7 pl-8 text-xs" :placeholder="t('consul.ui.searchLocalServices')" />
|
||||
</div>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-3">
|
||||
<ConsulListPagination :total="filteredLocalServiceItems.length" :page="localServicePage" :page-size="CONSUL_LIST_PAGE_SIZE" @update:page="updateLocalServicePage" />
|
||||
<Badge :variant="agentTargetStatus === 'writable' ? 'secondary' : 'outline'" :class="agentTargetStatus === 'writable' ? 'text-emerald-700 dark:text-emerald-300' : 'text-muted-foreground'"
|
||||
><Loader2 v-if="agentTargetStatus === 'verifying'" class="mr-1 h-3 w-3 animate-spin" /><CircleCheck v-else-if="agentTargetStatus === 'writable'" class="mr-1 h-3 w-3" /><LockKeyhole v-else class="mr-1 h-3 w-3" />{{
|
||||
agentTargetStatus === "verifying" ? t("consul.ui.agentTargetVerifying") : agentTargetStatus === "writable" ? t("consul.ui.agentWriteReady") : t("consul.ui.agentReadOnly")
|
||||
}}</Badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!canAgentWrite && (identity || !agentLoading)" class="flex items-start gap-2 border-b border-amber-300/70 bg-amber-50/70 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/20 dark:text-amber-200">
|
||||
<LockKeyhole class="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{{ t("consul.ui.agentWriteDisabledHint", { target: identity ? `${identity.node} (${identity.address})` : t("consul.ui.unknownWritesDisabled") }) }}</span>
|
||||
</div>
|
||||
<div v-for="service in pagedLocalServices" :key="service.ID" class="border-b last:border-0" :class="selectedLocalService?.ID === service.ID && 'bg-muted/20'">
|
||||
<div class="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<button type="button" class="flex min-w-0 items-start gap-3 text-left" :aria-expanded="selectedLocalService?.ID === service.ID" @click="openLocalService(service.ID)">
|
||||
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted"><ServerCog class="h-4 w-4 text-muted-foreground" /></span
|
||||
><span class="min-w-0 flex-1"
|
||||
><span class="flex min-w-0 flex-wrap items-center gap-2"
|
||||
><span class="truncate font-medium">{{ service.Service }}</span
|
||||
><code class="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{{ service.ID }}</code
|
||||
><Badge v-if="isServiceInMaintenance(service.ID)" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300"><Wrench class="mr-1 h-3 w-3" />{{ t("consul.ui.maintenance") }}</Badge></span
|
||||
><span class="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-muted-foreground"
|
||||
><span class="font-mono">{{ service.Address || identity?.address }}:{{ service.Port }}</span
|
||||
><Badge v-for="tag in service.Tags" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge></span
|
||||
></span
|
||||
>
|
||||
</button>
|
||||
<div v-if="canAgentWrite" class="flex shrink-0 items-center gap-1 lg:border-l lg:pl-3">
|
||||
<Button v-if="!isServiceInMaintenance(service.ID)" size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs" :disabled="maintenancePendingId === service.ID" :title="t('consul.ui.enableMaintenance')" @click.stop="maintenance(service.ID, true)"
|
||||
><Loader2 v-if="maintenancePendingId === service.ID" class="h-3.5 w-3.5 animate-spin" /><Wrench v-else class="h-3.5 w-3.5" />{{ t("consul.ui.enableMaintenance") }}</Button
|
||||
><Button v-else size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs text-amber-700 hover:text-amber-700 dark:text-amber-300" :disabled="maintenancePendingId === service.ID" :title="t('consul.ui.disableMaintenance')" @click.stop="maintenance(service.ID, false)"
|
||||
><Loader2 v-if="maintenancePendingId === service.ID" class="h-3.5 w-3.5 animate-spin" /><CircleCheck v-else class="h-3.5 w-3.5" />{{ t("consul.ui.disableMaintenance") }}</Button
|
||||
><Button size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs text-destructive hover:text-destructive" :title="t('consul.ui.deregister')" @click.stop="deregister(service.ID)"><Trash2 class="h-3.5 w-3.5" />{{ t("consul.ui.deregister") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedLocalService?.ID === service.ID" class="border-t bg-background px-4 py-3 text-xs">
|
||||
<dl class="grid overflow-hidden rounded-md border sm:grid-cols-4">
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.kind") }}</dt>
|
||||
<dd class="mt-1 font-medium">{{ selectedLocalService.Kind || t("consul.ui.standard") }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.datacenter") }}</dt>
|
||||
<dd class="mt-1 font-medium">{{ selectedLocalService.Datacenter || "-" }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2 sm:border-r">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.enableTagOverride") }}</dt>
|
||||
<dd class="mt-1 font-mono">{{ selectedLocalService.EnableTagOverride }}</dd>
|
||||
</div>
|
||||
<div class="border-b px-3 py-2">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.serviceWeights") }}</dt>
|
||||
<dd class="mt-1 font-mono">{{ selectedLocalService.Weights.Passing }} / {{ selectedLocalService.Weights.Warning }}</dd>
|
||||
</div>
|
||||
<div class="px-3 py-2 sm:col-span-4">
|
||||
<dt class="text-muted-foreground">{{ t("consul.ui.metadata") }}</dt>
|
||||
<dd class="mt-1 break-words font-mono">
|
||||
{{
|
||||
Object.entries(selectedLocalService.Meta)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ") || "-"
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!localServiceItems.length && agentLoading" class="px-3 py-8 text-center text-xs text-muted-foreground">{{ t("consul.ui.loading") }}</div>
|
||||
<div v-else-if="!localServiceItems.length" class="flex flex-col items-center gap-2 px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
<span>{{ t("consul.ui.noResources") }}</span
|
||||
><Button size="sm" variant="outline" class="h-7" @click="loadAgentData()"><RefreshCcw class="mr-1 h-3.5 w-3.5" />{{ t("consul.ui.refresh") }}</Button>
|
||||
</div>
|
||||
<div v-else-if="!filteredLocalServiceItems.length" class="px-3 py-8 text-center text-xs text-muted-foreground">{{ t("consul.ui.noMatchingServices") }}</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,648 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Clock3, KeyRound, Loader2, Plus, RefreshCcw, RotateCw, Search, ShieldCheck, Trash2 } from "@lucide/vue";
|
||||
import ConsulListPagination from "@/components/consul/ConsulListPagination.vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/lib/consul/pagination";
|
||||
import { consulSessionRenewDelayMs, parseConsulDurationMs } from "@/lib/consul/sessionRenew";
|
||||
import { normalizeConsulSession } from "@/lib/consul/sessionModel";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsulResponseMetadata, ConsulSession, ConsulSessionKeysResponse, ConsulSessionServiceCheck } from "@/types/consul";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
|
||||
function behaviorLabel(behavior: string) {
|
||||
if (behavior === "delete") return t("consul.ui.behaviorDelete");
|
||||
if (behavior === "release") return t("consul.ui.behaviorRelease");
|
||||
return behavior || t("consul.ui.unknown");
|
||||
}
|
||||
const connectionActive = computed(() => store.connectedIds.has(props.connectionId));
|
||||
|
||||
const sessions = ref<ConsulSession[]>([]);
|
||||
const responseMetadata = ref<ConsulResponseMetadata | null>(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
const createOpen = ref(false);
|
||||
const lockOpen = ref(false);
|
||||
const expandedSessionId = ref("");
|
||||
const heldKeys = ref<Record<string, ConsulSessionKeysResponse>>({});
|
||||
const heldKeysLoading = ref("");
|
||||
|
||||
const search = ref("");
|
||||
const ttlFilter = ref<"any" | "ttl" | "persistent">("any");
|
||||
const behaviorFilter = ref<"any" | "release" | "delete">("any");
|
||||
const page = ref(1);
|
||||
|
||||
const createForm = ref({
|
||||
name: "",
|
||||
node: "",
|
||||
ttl: "30s",
|
||||
lockDelay: "15s",
|
||||
behavior: "release" as "release" | "delete",
|
||||
nodeChecks: "serfHealth",
|
||||
serviceChecks: "",
|
||||
});
|
||||
const lockForm = ref({ key: "", session: "", value: "" });
|
||||
const lockInspection = ref<{ inspected: boolean; found: boolean; modifyIndex: string; owner: string; flags: string }>({ inspected: false, found: false, modifyIndex: "0", owner: "", flags: "0" });
|
||||
const lockInspecting = ref(false);
|
||||
|
||||
const autoRenewId = ref("");
|
||||
let renewTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let renewContext = "";
|
||||
let sequence = 0;
|
||||
|
||||
const filteredSessions = computed(() => {
|
||||
const keyword = search.value.trim().toLocaleLowerCase();
|
||||
return sessions.value.filter((session) => {
|
||||
if (ttlFilter.value === "ttl" && !session.TTL.trim()) return false;
|
||||
if (ttlFilter.value === "persistent" && session.TTL.trim()) return false;
|
||||
if (behaviorFilter.value !== "any" && session.Behavior !== behaviorFilter.value) return false;
|
||||
if (!keyword) return true;
|
||||
const checks = [...session.NodeChecks, ...session.ServiceChecks.flatMap((check) => [check.ID, check.Namespace])];
|
||||
return [session.Name, session.ID, session.Node, session.TTL, session.Behavior, ...checks].join("\n").toLocaleLowerCase().includes(keyword);
|
||||
});
|
||||
});
|
||||
const pagedSessions = computed(() => paginateConsulItems(filteredSessions.value, page.value));
|
||||
const hasFilters = computed(() => Boolean(search.value.trim()) || ttlFilter.value !== "any" || behaviorFilter.value !== "any");
|
||||
|
||||
function message(value: unknown) {
|
||||
return value instanceof Error ? value.message : String(value);
|
||||
}
|
||||
|
||||
async function withUiTimeout<T>(operation: Promise<T>, milliseconds = 15_000): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(t("consul.ui.sessionRequestTimedOut"))), milliseconds);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function currentScopeKey() {
|
||||
const external = store.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
return [config.datacenter || config.consulDatacenter || config.consul_datacenter, config.namespace || config.consulNamespace || config.consul_namespace, config.partition || config.consulPartition || config.consul_partition].map((value) => String(value || "")).join("\u0000");
|
||||
}
|
||||
|
||||
function currentRenewContext() {
|
||||
return `${props.connectionId}\u0000${consulStore.generation}\u0000${currentScopeKey()}`;
|
||||
}
|
||||
|
||||
function isTtlSession(session: ConsulSession) {
|
||||
return Boolean(session.TTL.trim());
|
||||
}
|
||||
|
||||
function formatLockDelay(value: number) {
|
||||
const seconds = value / 1_000_000_000;
|
||||
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}s`;
|
||||
}
|
||||
|
||||
function sessionChecks(session: ConsulSession) {
|
||||
const nodeChecks = session.NodeChecks.map((check) => `${t("consul.ui.nodeCheckShort")}: ${check}`);
|
||||
const serviceChecks = session.ServiceChecks.map((check) => `${t("consul.ui.serviceCheckShort")}: ${check.ID}${check.Namespace ? ` @ ${check.Namespace}` : ""}`);
|
||||
return [...nodeChecks, ...serviceChecks];
|
||||
}
|
||||
|
||||
function parseServiceChecks(value: string): ConsulSessionServiceCheck[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const [id, namespace = ""] = part.split("@", 2).map((item) => item.trim());
|
||||
return { ID: id, Namespace: namespace };
|
||||
});
|
||||
}
|
||||
|
||||
function validateCreateForm() {
|
||||
const ttl = createForm.value.ttl.trim();
|
||||
const ttlMs = ttl ? parseConsulDurationMs(ttl) : null;
|
||||
if (ttl && (ttlMs === null || ttlMs < 10_000 || ttlMs > 86_400_000)) {
|
||||
return t("consul.ui.invalidSessionTtl");
|
||||
}
|
||||
const delay = createForm.value.lockDelay.trim();
|
||||
const delayMs = delay ? parseConsulDurationMs(delay) : null;
|
||||
if (delay && (delayMs === null || delayMs < 0 || delayMs > 60_000)) {
|
||||
return t("consul.ui.invalidLockDelay");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const value = await withUiTimeout(api.consulSessions(props.connectionId));
|
||||
if (current !== sequence) return;
|
||||
sessions.value = value.items.map(normalizeConsulSession);
|
||||
responseMetadata.value = value.metadata;
|
||||
page.value = clampConsulPage(page.value, filteredSessions.value.length);
|
||||
if (autoRenewId.value && !sessions.value.some((session) => session.ID === autoRenewId.value)) stopAutoRenew();
|
||||
if (!lockForm.value.session && sessions.value.length) lockForm.value.session = sessions.value[0].ID;
|
||||
} catch (value) {
|
||||
if (current === sequence) error.value = message(value);
|
||||
} finally {
|
||||
if (current === sequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession() {
|
||||
if (readOnly.value) return;
|
||||
const validation = validateCreateForm();
|
||||
if (validation) {
|
||||
error.value = validation;
|
||||
return;
|
||||
}
|
||||
if (createForm.value.behavior === "delete" && !window.confirm(t("consul.ui.deleteBehaviorWarning"))) return;
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const created = await api.consulCreateSession(props.connectionId, {
|
||||
name: createForm.value.name,
|
||||
node: createForm.value.node,
|
||||
ttl: createForm.value.ttl.trim() || null,
|
||||
lockDelay: createForm.value.lockDelay.trim() || null,
|
||||
behavior: createForm.value.behavior,
|
||||
nodeChecks: createForm.value.nodeChecks
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
serviceChecks: parseServiceChecks(createForm.value.serviceChecks),
|
||||
});
|
||||
createOpen.value = false;
|
||||
await load();
|
||||
expandedSessionId.value = created.ID;
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renew(id: string) {
|
||||
const session = sessions.value.find((item) => item.ID === id);
|
||||
if (!session || !isTtlSession(session)) {
|
||||
error.value = t("consul.ui.noRenewRequired");
|
||||
stopAutoRenew();
|
||||
return false;
|
||||
}
|
||||
if (readOnly.value || !connectionActive.value) {
|
||||
stopAutoRenew();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const renewed = await api.consulRenewSession(props.connectionId, id);
|
||||
const index = sessions.value.findIndex((item) => item.ID === id);
|
||||
if (index >= 0) sessions.value[index] = renewed;
|
||||
return true;
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
stopAutoRenew();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function destroy(id: string) {
|
||||
if (readOnly.value) return;
|
||||
try {
|
||||
const impact = await api.consulSessionDestroyImpact(props.connectionId, id);
|
||||
if (!impact.complete) {
|
||||
error.value = t("consul.ui.sessionImpactIncomplete");
|
||||
return;
|
||||
}
|
||||
const keys = impact.heldKeys.map((item) => item.key).join(", ") || t("consul.ui.noHeldKeys");
|
||||
if (
|
||||
!window.confirm(
|
||||
t("consul.ui.destroySessionImpact", {
|
||||
id,
|
||||
behavior: impact.session.Behavior,
|
||||
count: impact.heldKeys.length,
|
||||
keys,
|
||||
}),
|
||||
)
|
||||
)
|
||||
return;
|
||||
await api.consulDestroySession(props.connectionId, {
|
||||
id,
|
||||
expectedBehavior: impact.session.Behavior,
|
||||
expectedHeldKeys: impact.heldKeys,
|
||||
});
|
||||
if (autoRenewId.value === id) stopAutoRenew();
|
||||
delete heldKeys.value[id];
|
||||
if (expandedSessionId.value === id) expandedSessionId.value = "";
|
||||
await load();
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetails(id: string) {
|
||||
expandedSessionId.value = expandedSessionId.value === id ? "" : id;
|
||||
}
|
||||
|
||||
async function toggleHeldKeys(id: string) {
|
||||
if (expandedSessionId.value === id && heldKeys.value[id]) {
|
||||
expandedSessionId.value = "";
|
||||
return;
|
||||
}
|
||||
expandedSessionId.value = id;
|
||||
heldKeysLoading.value = id;
|
||||
try {
|
||||
heldKeys.value[id] = await api.consulSessionKeys(props.connectionId, id);
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
} finally {
|
||||
if (heldKeysLoading.value === id) heldKeysLoading.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoRenew(id: string) {
|
||||
const session = sessions.value.find((item) => item.ID === id);
|
||||
if (!session || !isTtlSession(session) || readOnly.value || !connectionActive.value || renewContext !== currentRenewContext()) {
|
||||
stopAutoRenew();
|
||||
return;
|
||||
}
|
||||
renewTimer = setTimeout(async () => {
|
||||
renewTimer = undefined;
|
||||
if (autoRenewId.value !== id || renewContext !== currentRenewContext()) {
|
||||
stopAutoRenew();
|
||||
return;
|
||||
}
|
||||
if (await renew(id)) scheduleAutoRenew(id);
|
||||
}, consulSessionRenewDelayMs(session.TTL));
|
||||
}
|
||||
|
||||
function startAutoRenew(id: string) {
|
||||
stopAutoRenew();
|
||||
const session = sessions.value.find((item) => item.ID === id);
|
||||
if (!session || !isTtlSession(session) || !connectionActive.value) return;
|
||||
autoRenewId.value = id;
|
||||
renewContext = currentRenewContext();
|
||||
scheduleAutoRenew(id);
|
||||
}
|
||||
|
||||
function stopAutoRenew() {
|
||||
if (renewTimer) clearTimeout(renewTimer);
|
||||
renewTimer = undefined;
|
||||
renewContext = "";
|
||||
autoRenewId.value = "";
|
||||
}
|
||||
|
||||
function resetLockInspection() {
|
||||
lockInspection.value = { inspected: false, found: false, modifyIndex: "0", owner: "", flags: "0" };
|
||||
}
|
||||
|
||||
async function inspectLockKey() {
|
||||
const key = lockForm.value.key.trim();
|
||||
if (!key) {
|
||||
resetLockInspection();
|
||||
return false;
|
||||
}
|
||||
lockInspecting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await api.consulGet(props.connectionId, key);
|
||||
lockInspection.value = {
|
||||
inspected: true,
|
||||
found: result.found,
|
||||
modifyIndex: String(result.metadata?.modRevision ?? 0),
|
||||
owner: String(result.metadata?.session ?? ""),
|
||||
flags: String(result.metadata?.flags ?? 0),
|
||||
};
|
||||
return true;
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
return false;
|
||||
} finally {
|
||||
lockInspecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquire() {
|
||||
if (readOnly.value || !lockForm.value.key.trim() || !lockForm.value.session) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (!(await inspectLockKey())) return;
|
||||
if (lockInspection.value.owner && lockInspection.value.owner !== lockForm.value.session) {
|
||||
error.value = t("consul.ui.keyHeldBy", { session: lockInspection.value.owner });
|
||||
return;
|
||||
}
|
||||
await api.consulAcquireLock(props.connectionId, {
|
||||
key: lockForm.value.key.trim(),
|
||||
session: lockForm.value.session,
|
||||
value: { encoding: "utf8", data: lockForm.value.value },
|
||||
flags: null,
|
||||
expectedModifyIndex: lockInspection.value.modifyIndex,
|
||||
});
|
||||
await inspectLockKey();
|
||||
delete heldKeys.value[lockForm.value.session];
|
||||
expandedSessionId.value = "";
|
||||
await toggleHeldKeys(lockForm.value.session);
|
||||
await load();
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function release() {
|
||||
if (readOnly.value || !lockForm.value.key.trim() || !lockForm.value.session) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (!(await inspectLockKey())) return;
|
||||
if (!lockInspection.value.found) {
|
||||
error.value = t("consul.ui.keyNotFound");
|
||||
return;
|
||||
}
|
||||
if (lockInspection.value.owner !== lockForm.value.session) {
|
||||
error.value = lockInspection.value.owner ? t("consul.ui.keyHeldBy", { session: lockInspection.value.owner }) : t("consul.ui.keyUnlocked");
|
||||
return;
|
||||
}
|
||||
await api.consulReleaseLock(props.connectionId, lockForm.value.key.trim(), lockForm.value.session);
|
||||
await inspectLockKey();
|
||||
delete heldKeys.value[lockForm.value.session];
|
||||
await load();
|
||||
} catch (value) {
|
||||
error.value = message(value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search.value = "";
|
||||
ttlFilter.value = "any";
|
||||
behaviorFilter.value = "any";
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
stopAutoRenew();
|
||||
expandedSessionId.value = "";
|
||||
heldKeys.value = {};
|
||||
resetLockInspection();
|
||||
void load();
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => [consulStore.generation, currentScopeKey(), connectionActive.value, readOnly.value] as const,
|
||||
() => stopAutoRenew(),
|
||||
);
|
||||
watch([search, ttlFilter, behaviorFilter], () => {
|
||||
page.value = 1;
|
||||
});
|
||||
watch(() => lockForm.value.key, resetLockInspection);
|
||||
onMounted(load);
|
||||
onDeactivated(stopAutoRenew);
|
||||
onBeforeUnmount(stopAutoRenew);
|
||||
defineExpose({ refresh: () => (void load(), true) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full overflow-auto p-4">
|
||||
<header class="mb-4 flex flex-wrap items-start justify-between gap-3 border-b pb-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold">{{ t("consul.ui.sessionsAndLocks") }}</h2>
|
||||
<p class="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">{{ t("consul.ui.sessionDescription") }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="icon" variant="outline" class="h-8 w-8" :disabled="loading" :title="t('consul.ui.refresh')" @click="load">
|
||||
<RefreshCcw class="h-3.5 w-3.5" :class="loading && 'animate-spin'" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly" @click="lockOpen = !lockOpen"> <KeyRound class="h-3.5 w-3.5" />{{ t("consul.ui.lock") }} </Button>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="readOnly" @click="createOpen = !createOpen"> <Plus class="h-3.5 w-3.5" />{{ t("consul.ui.create") }} </Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">{{ error }}</div>
|
||||
|
||||
<section v-if="createOpen" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="border-b bg-muted/30 px-4 py-3 text-sm font-medium">{{ t("consul.ui.createSession") }}</div>
|
||||
<div class="grid gap-x-4 gap-y-3 p-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.name") }}</Label
|
||||
><Input v-model="createForm.name" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.nodeOptional") }}</Label
|
||||
><Input v-model="createForm.node" class="h-9" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.ttlOptional") }}</Label
|
||||
><Input v-model="createForm.ttl" class="h-9" placeholder="30s" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("consul.ui.ttlRangeHint") }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.lockDelay") }}</Label
|
||||
><Input v-model="createForm.lockDelay" class="h-9" placeholder="15s" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("consul.ui.lockDelayHint") }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.behavior") }}</Label
|
||||
><select v-model="createForm.behavior" class="h-9 w-full rounded-md border bg-background px-2 text-sm">
|
||||
<option value="release">{{ t("consul.ui.releaseLocks") }}</option>
|
||||
<option value="delete">{{ t("consul.ui.deleteKeys") }}</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("consul.ui.behaviorHint") }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.nodeChecks") }}</Label
|
||||
><Input v-model="createForm.nodeChecks" class="h-9" placeholder="serfHealth" />
|
||||
</div>
|
||||
<div class="space-y-1.5 md:col-span-2 xl:col-span-3">
|
||||
<Label class="text-xs">{{ t("consul.ui.serviceChecks") }}</Label
|
||||
><Input v-model="createForm.serviceChecks" class="h-9" :placeholder="t('consul.ui.serviceChecksPlaceholder')" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("consul.ui.serviceChecksHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 border-t bg-muted/20 px-4 py-3">
|
||||
<Button size="sm" variant="outline" @click="createOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button size="sm" :disabled="saving" @click="createSession"><Loader2 v-if="saving" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("consul.ui.create") }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="lockOpen" class="mb-4 overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/30 px-4 py-3">
|
||||
<span class="text-sm font-medium">{{ t("consul.ui.lockManagement") }}</span
|
||||
><span class="text-xs text-muted-foreground">{{ t("consul.ui.lockHint") }}</span>
|
||||
</div>
|
||||
<div class="grid gap-x-4 gap-y-3 p-4 md:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.key") }}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="lockForm.key" class="h-9" @keydown.enter="inspectLockKey" /><Button size="sm" variant="outline" class="h-9 shrink-0" :disabled="lockInspecting || !lockForm.key.trim()" @click="inspectLockKey"
|
||||
><Loader2 v-if="lockInspecting" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("consul.ui.inspectKey") }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("consul.ui.selectSession") }}</Label
|
||||
><select v-model="lockForm.session" class="h-9 w-full rounded-md border bg-background px-2 text-sm">
|
||||
<option value="" disabled>{{ t("consul.ui.selectSession") }}</option>
|
||||
<option v-for="session in sessions" :key="session.ID" :value="session.ID">{{ session.Name || t("consul.ui.unnamedSession") }} · {{ session.ID }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1.5 md:col-span-2">
|
||||
<Label class="text-xs">{{ t("consul.ui.value") }}</Label
|
||||
><textarea v-model="lockForm.value" class="min-h-20 w-full resize-y rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div v-if="lockInspection.inspected" class="md:col-span-2 flex flex-wrap items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs">
|
||||
<Badge variant="secondary">{{ lockInspection.found ? t("consul.ui.keyExists") : t("consul.ui.keyNotFound") }}</Badge>
|
||||
<span>{{ t("consul.ui.modifyIndexValue", { index: lockInspection.modifyIndex }) }}</span>
|
||||
<span v-if="lockInspection.owner" class="text-amber-700 dark:text-amber-300">{{ t("consul.ui.keyHeldBy", { session: lockInspection.owner }) }}</span>
|
||||
<span v-else-if="lockInspection.found" class="text-muted-foreground">{{ t("consul.ui.keyUnlocked") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 border-t bg-muted/20 px-4 py-3">
|
||||
<Button size="sm" variant="outline" @click="lockOpen = false">{{ t("consul.ui.close") }}</Button
|
||||
><Button size="sm" variant="outline" :disabled="saving || !lockForm.key.trim() || !lockForm.session" @click="release">{{ t("consul.ui.releaseMatchingSession") }}</Button
|
||||
><Button size="sm" :disabled="saving || !lockForm.key.trim() || !lockForm.session" @click="acquire">{{ t("consul.ui.acquire") }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 rounded-lg border bg-muted/10 p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative min-w-64 flex-1"><Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /><Input v-model="search" class="h-9 pl-8" :placeholder="t('consul.ui.searchSessions')" /></div>
|
||||
<select v-model="ttlFilter" class="h-9 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allSessionTypes") }}</option>
|
||||
<option value="ttl">{{ t("consul.ui.ttlSessions") }}</option>
|
||||
<option value="persistent">{{ t("consul.ui.persistentSessions") }}</option>
|
||||
</select>
|
||||
<select v-model="behaviorFilter" class="h-9 rounded-md border bg-background px-2 text-xs">
|
||||
<option value="any">{{ t("consul.ui.allBehaviors") }}</option>
|
||||
<option value="release">{{ t("consul.ui.behaviorRelease") }}</option>
|
||||
<option value="delete">{{ t("consul.ui.behaviorDelete") }}</option>
|
||||
</select>
|
||||
<Button v-if="hasFilters" size="sm" variant="ghost" class="h-9" @click="clearFilters">{{ t("consul.ui.clearFilters") }}</Button>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t("consul.ui.sessionResultCount", { filtered: filteredSessions.length, total: sessions.length }) }}</span>
|
||||
<span v-if="responseMetadata?.filteredByAcls" class="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300"><AlertTriangle class="h-3.5 w-3.5" />{{ t("consul.ui.aclFilteredSessions") }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{{ t("consul.ui.sessions") }}</span
|
||||
><Badge variant="secondary">{{ filteredSessions.length }}</Badge>
|
||||
</div>
|
||||
<ConsulListPagination
|
||||
:total="filteredSessions.length"
|
||||
:page="page"
|
||||
:page-size="CONSUL_LIST_PAGE_SIZE"
|
||||
@update:page="
|
||||
page = $event;
|
||||
expandedSessionId = '';
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-for="session in pagedSessions" :key="session.ID" class="border-b last:border-0">
|
||||
<div class="grid cursor-pointer items-center gap-3 px-3 py-3 text-xs transition-colors hover:bg-muted/20 md:grid-cols-[minmax(0,1.4fr)_minmax(7rem,.7fr)_minmax(9rem,.8fr)_auto]" @click="toggleDetails(session.ID)">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" tabindex="-1"><ChevronUp v-if="expandedSessionId === session.ID" class="h-3.5 w-3.5" /><ChevronDown v-else class="h-3.5 w-3.5" /></Button>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ session.Name || t("consul.ui.unnamedSession") }}</div>
|
||||
<div class="truncate font-mono text-[11px] text-muted-foreground">{{ session.ID }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">{{ session.Node || "—" }}</div>
|
||||
<div class="truncate text-muted-foreground">{{ session.Namespace || t("consul.ui.default") }} / {{ session.Partition || t("consul.ui.default") }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Badge :variant="isTtlSession(session) ? 'default' : 'secondary'"><Clock3 class="mr-1 h-3 w-3" />{{ isTtlSession(session) ? session.TTL : t("consul.ui.noTtl") }}</Badge
|
||||
><Badge variant="outline">{{ behaviorLabel(session.Behavior) }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1" @click.stop>
|
||||
<Button size="sm" variant="ghost" class="h-7 gap-1" :disabled="heldKeysLoading === session.ID" @click="toggleHeldKeys(session.ID)"
|
||||
><Loader2 v-if="heldKeysLoading === session.ID" class="h-3.5 w-3.5 animate-spin" /><KeyRound v-else class="h-3.5 w-3.5" />{{ t("consul.ui.heldKeys") }}</Button
|
||||
>
|
||||
<Button v-if="isTtlSession(session)" size="icon" variant="ghost" class="h-7 w-7" :disabled="readOnly || !connectionActive" :title="t('consul.ui.renewNow')" @click="renew(session.ID)"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
<Button v-if="isTtlSession(session)" size="sm" variant="ghost" class="h-7 gap-1" :disabled="readOnly || !connectionActive" :title="t('consul.ui.autoRenewPageHint')" @click="autoRenewId === session.ID ? stopAutoRenew() : startAutoRenew(session.ID)"
|
||||
><RotateCw class="h-3.5 w-3.5" :class="autoRenewId === session.ID && 'animate-spin'" />{{ autoRenewId === session.ID ? t("consul.ui.stop") : t("consul.ui.autoRenew") }}</Button
|
||||
>
|
||||
<span v-else class="px-2 text-[11px] text-muted-foreground">{{ t("consul.ui.noRenewRequired") }}</span>
|
||||
<Button size="icon" variant="ghost" class="h-7 w-7 text-destructive" :disabled="readOnly" :title="t('consul.ui.destroy')" @click="destroy(session.ID)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedSessionId === session.ID" class="border-t bg-muted/10 p-3">
|
||||
<dl class="grid overflow-hidden rounded-md border bg-background sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.node") }}</dt>
|
||||
<dd class="mt-1 text-xs font-medium">{{ session.Node || "—" }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.lockDelay") }}</dt>
|
||||
<dd class="mt-1 font-mono text-xs">{{ formatLockDelay(session.LockDelay) }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">TTL</dt>
|
||||
<dd class="mt-1 font-mono text-xs">{{ session.TTL || t("consul.ui.noTtl") }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.behavior") }}</dt>
|
||||
<dd class="mt-1 text-xs font-medium">{{ behaviorLabel(session.Behavior) }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.createIndex") }}</dt>
|
||||
<dd class="mt-1 font-mono text-xs">{{ session.CreateIndex }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:border-r">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.modifyIndexLabel") }}</dt>
|
||||
<dd class="mt-1 font-mono text-xs">{{ session.ModifyIndex }}</dd>
|
||||
</div>
|
||||
<div class="border-b p-3 lg:col-span-2">
|
||||
<dt class="text-[11px] text-muted-foreground">{{ t("consul.ui.checks") }}</dt>
|
||||
<dd class="mt-1 flex flex-wrap gap-1">
|
||||
<Badge v-for="check in sessionChecks(session)" :key="check" variant="outline">{{ check }}</Badge
|
||||
><span v-if="!sessionChecks(session).length" class="text-xs text-muted-foreground">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="heldKeys[session.ID] || heldKeysLoading === session.ID" class="mt-3 overflow-hidden rounded-md border bg-background">
|
||||
<div class="flex items-center justify-between border-b bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs font-medium">
|
||||
<KeyRound class="h-3.5 w-3.5" />{{ t("consul.ui.heldKeys") }}<Badge v-if="heldKeys[session.ID]" variant="secondary">{{ heldKeys[session.ID].items.length }}</Badge>
|
||||
</div>
|
||||
<span v-if="heldKeys[session.ID] && !heldKeys[session.ID].complete" class="inline-flex items-center gap-1 text-[11px] text-amber-700 dark:text-amber-300"><AlertTriangle class="h-3.5 w-3.5" />{{ t("consul.ui.heldKeysIncomplete") }}</span
|
||||
><span v-else-if="heldKeys[session.ID]?.complete" class="inline-flex items-center gap-1 text-[11px] text-emerald-700 dark:text-emerald-300"><ShieldCheck class="h-3.5 w-3.5" />{{ t("consul.ui.complete") }}</span>
|
||||
</div>
|
||||
<div v-if="heldKeysLoading === session.ID" class="flex items-center justify-center gap-2 p-4 text-xs text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
<template v-else
|
||||
><div v-for="item in heldKeys[session.ID]?.items || []" :key="item.key" class="flex items-center justify-between gap-3 border-b px-3 py-2 font-mono text-xs last:border-0">
|
||||
<span class="min-w-0 break-all">{{ item.key }}</span
|
||||
><span class="shrink-0 text-muted-foreground">{{ t("consul.ui.lockIndex", { index: item.lockIndex }) }}</span>
|
||||
</div>
|
||||
<div v-if="!heldKeys[session.ID]?.items.length" class="p-3 text-xs text-muted-foreground">{{ t("consul.ui.noHeldKeys") }}</div></template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!pagedSessions.length && !loading" class="p-8 text-center text-xs text-muted-foreground">{{ hasFilters ? t("consul.ui.noMatchingSessions") : t("consul.ui.noVisibleSessions") }}</div>
|
||||
<div v-if="loading && !sessions.length" class="flex items-center justify-center gap-2 p-8 text-xs text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("consul.ui.loading") }}</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { FileSearch, Pencil, Play, Plus, RefreshCcw, Trash2, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulCapabilities, ConsulCoordinate, ConsulEvent, ConsulPreparedQuery, ConsulPreparedQueryExecuteResponse, ConsulPreparedQueryNode } from "@/types/consul";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
type ToolTab = "queries" | "events" | "coordinates";
|
||||
|
||||
const props = defineProps<{ connectionId: string; capabilities?: ConsulCapabilities | null }>();
|
||||
const { t } = useI18n();
|
||||
function capabilityLabel(value?: string) {
|
||||
if (value === "supported") return t("consul.ui.capabilitySupported");
|
||||
if (value === "unsupported") return t("consul.ui.capabilityUnsupported");
|
||||
if (value === "disabled") return t("consul.ui.capabilityDisabled");
|
||||
if (value === "forbidden") return t("consul.ui.capabilityForbidden");
|
||||
return t("consul.ui.capabilityUnknown");
|
||||
}
|
||||
const connectionStore = useConnectionStore();
|
||||
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
||||
const canWriteQueries = computed(() => !readOnly.value && props.capabilities?.preparedQueries === "supported");
|
||||
const canFireEvents = computed(() => !readOnly.value && props.capabilities?.events === "supported");
|
||||
const queryAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.preparedQueries || ""));
|
||||
const eventAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.events || ""));
|
||||
const coordinateAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.coordinates || ""));
|
||||
|
||||
const activeTab = ref<ToolTab>("queries");
|
||||
const queries = ref<ConsulPreparedQuery[]>([]);
|
||||
const events = ref<ConsulEvent[]>([]);
|
||||
const coordinates = ref<ConsulCoordinate[]>([]);
|
||||
const queryLoading = ref(false);
|
||||
const eventLoading = ref(false);
|
||||
const coordinateLoading = ref(false);
|
||||
const queryBusy = ref(false);
|
||||
const eventBusy = ref(false);
|
||||
const queryError = ref("");
|
||||
const eventError = ref("");
|
||||
const coordinateError = ref("");
|
||||
const activeLoading = computed(() => (activeTab.value === "queries" ? queryLoading.value : activeTab.value === "events" ? eventLoading.value : coordinateLoading.value));
|
||||
|
||||
const queryName = ref("");
|
||||
const queryService = ref("");
|
||||
const queryResult = ref<ConsulPreparedQueryExecuteResponse | null>(null);
|
||||
const queryDetails = ref<unknown | null>(null);
|
||||
const editingQueryId = ref("");
|
||||
|
||||
const eventName = ref("");
|
||||
const eventPayload = ref("");
|
||||
const eventNodeFilter = ref("");
|
||||
const eventServiceFilter = ref("");
|
||||
const eventTagFilter = ref("");
|
||||
const eventPayloadBytes = computed(() => new TextEncoder().encode(eventPayload.value).length);
|
||||
const eventValid = computed(() => {
|
||||
const name = eventName.value.trim();
|
||||
return Boolean(name && !name.startsWith("_") && new TextEncoder().encode(name).length <= 256 && eventPayloadBytes.value <= 100);
|
||||
});
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function loadQueries() {
|
||||
if (!queryAvailable.value) return;
|
||||
queryLoading.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
queries.value = await api.consulPreparedQueryList(props.connectionId);
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
if (!eventAvailable.value) return;
|
||||
eventLoading.value = true;
|
||||
eventError.value = "";
|
||||
try {
|
||||
events.value = await api.consulEventList(props.connectionId);
|
||||
} catch (error) {
|
||||
eventError.value = errorMessage(error);
|
||||
} finally {
|
||||
eventLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoordinates() {
|
||||
if (!coordinateAvailable.value) return;
|
||||
coordinateLoading.value = true;
|
||||
coordinateError.value = "";
|
||||
try {
|
||||
coordinates.value = await api.consulCoordinateNodes(props.connectionId);
|
||||
} catch (error) {
|
||||
coordinateError.value = errorMessage(error);
|
||||
} finally {
|
||||
coordinateLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (activeTab.value === "queries") await loadQueries();
|
||||
else if (activeTab.value === "events") await loadEvents();
|
||||
else await loadCoordinates();
|
||||
}
|
||||
|
||||
async function createQuery() {
|
||||
if (!canWriteQueries.value || !queryName.value.trim() || !queryService.value.trim()) return;
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
const input = {
|
||||
name: queryName.value.trim(),
|
||||
session: "",
|
||||
service: { Service: queryService.value.trim(), Near: "", OnlyPassing: false, Tags: [] },
|
||||
};
|
||||
if (editingQueryId.value) await api.consulPreparedQueryUpdate(props.connectionId, editingQueryId.value, input);
|
||||
else await api.consulPreparedQueryCreate(props.connectionId, input);
|
||||
cancelQueryEdit();
|
||||
await loadQueries();
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelQueryEdit() {
|
||||
editingQueryId.value = "";
|
||||
queryName.value = "";
|
||||
queryService.value = "";
|
||||
}
|
||||
|
||||
async function editQuery(query: ConsulPreparedQuery) {
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
const current = await api.consulPreparedQueryRead(props.connectionId, query.ID);
|
||||
queryDetails.value = current;
|
||||
editingQueryId.value = current.ID;
|
||||
queryName.value = current.Name;
|
||||
queryService.value = current.Service.Service;
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function explainQuery(query: ConsulPreparedQuery) {
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
queryDetails.value = await api.consulPreparedQueryExplain(props.connectionId, query.ID);
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteQuery(id: string) {
|
||||
if (!canWriteQueries.value || !confirm(t("consul.ui.deletePreparedQuery", { id }))) return;
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
try {
|
||||
await api.consulPreparedQueryDelete(props.connectionId, id);
|
||||
if (editingQueryId.value === id) cancelQueryEdit();
|
||||
await loadQueries();
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQuery(query: ConsulPreparedQuery) {
|
||||
queryBusy.value = true;
|
||||
queryError.value = "";
|
||||
queryDetails.value = null;
|
||||
try {
|
||||
queryResult.value = await api.consulPreparedQueryExecute(props.connectionId, { query: query.ID, limit: 100, connect: false });
|
||||
} catch (error) {
|
||||
queryError.value = errorMessage(error);
|
||||
} finally {
|
||||
queryBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function queryEndpoint(item: ConsulPreparedQueryNode) {
|
||||
const address = item.Service.Address || item.Node.Address || "-";
|
||||
return item.Service.Port ? `${address}:${item.Service.Port}` : address;
|
||||
}
|
||||
|
||||
async function fireEvent() {
|
||||
if (!canFireEvents.value || !eventValid.value) return;
|
||||
eventBusy.value = true;
|
||||
eventError.value = "";
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(eventPayload.value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
await api.consulEventFire(props.connectionId, {
|
||||
name: eventName.value.trim(),
|
||||
payloadBase64: btoa(binary),
|
||||
nodeFilter: eventNodeFilter.value.trim(),
|
||||
serviceFilter: eventServiceFilter.value.trim(),
|
||||
tagFilter: eventTagFilter.value.trim(),
|
||||
});
|
||||
eventPayload.value = "";
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
eventError.value = errorMessage(error);
|
||||
} finally {
|
||||
eventBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEventPayload(payload: string | null) {
|
||||
if (!payload) return "";
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(payload), (character) => character.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, () => {
|
||||
void refresh();
|
||||
});
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
queries.value = [];
|
||||
events.value = [];
|
||||
coordinates.value = [];
|
||||
queryResult.value = null;
|
||||
queryDetails.value = null;
|
||||
cancelQueryEdit();
|
||||
void refresh();
|
||||
},
|
||||
);
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex h-10 shrink-0 items-center justify-between border-b px-3">
|
||||
<span class="text-sm font-medium">{{ t("consul.ui.tools") }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="activeLoading" :title="t('consul.ui.refresh')" @click="refresh">
|
||||
<RefreshCcw class="h-3.5 w-3.5" :class="activeLoading ? 'animate-spin' : ''" />
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList class="mx-3 mt-3 h-8 w-fit">
|
||||
<TabsTrigger value="queries">{{ t("consul.ui.preparedQueries") }}</TabsTrigger>
|
||||
<TabsTrigger value="events">{{ t("consul.ui.events") }}</TabsTrigger>
|
||||
<TabsTrigger value="coordinates">{{ t("consul.ui.coordinates") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="queries" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<p class="mb-3 text-sm text-muted-foreground">{{ t("consul.ui.preparedQueriesDescription") }}</p>
|
||||
<div v-if="capabilities && capabilities.preparedQueries !== 'supported'" class="mb-3 rounded-md border p-3 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capabilities.preparedQueries) }) }}</div>
|
||||
<div v-if="queryError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ queryError }}</div>
|
||||
<div v-if="canWriteQueries" class="mb-3 grid grid-cols-[minmax(12rem,1fr)_minmax(12rem,1fr)_auto_auto] gap-2">
|
||||
<Input v-model="queryName" :placeholder="t('consul.ui.queryName')" />
|
||||
<Input v-model="queryService" :placeholder="t('consul.ui.targetService')" />
|
||||
<Button size="sm" :disabled="queryBusy || !queryName.trim() || !queryService.trim()" @click="createQuery"> <Pencil v-if="editingQueryId" class="mr-1 h-3.5 w-3.5" /><Plus v-else class="mr-1 h-3.5 w-3.5" />{{ editingQueryId ? t("consul.ui.update") : t("consul.ui.create") }} </Button>
|
||||
<Button v-if="editingQueryId" variant="ghost" size="icon" class="h-8 w-8" :title="t('consul.ui.cancelEdit')" @click="cancelQueryEdit"><X class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<div v-for="query in queries" :key="query.ID" class="flex min-h-12 items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium">{{ query.Name || query.ID }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ t("consul.ui.targetServiceValue", { service: query.Service.Service }) }} · {{ t("consul.ui.id") }} {{ query.ID }}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="queryBusy" :title="t('consul.ui.execute')" @click="executeQuery(query)"><Play class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="queryBusy" :title="t('consul.ui.explain')" @click="explainQuery(query)"><FileSearch class="h-3.5 w-3.5" /></Button>
|
||||
<Button v-if="canWriteQueries" variant="ghost" size="icon" class="h-7 w-7" :disabled="queryBusy" :title="t('consul.ui.edit')" @click="editQuery(query)"><Pencil class="h-3.5 w-3.5" /></Button>
|
||||
<Button v-if="canWriteQueries" variant="ghost" size="icon" class="h-7 w-7 text-destructive" :disabled="queryBusy" :title="t('consul.ui.delete')" @click="deleteQuery(query.ID)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="!queryLoading && queryAvailable && queries.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noPreparedQueries") }}</div>
|
||||
<div v-if="queryLoading" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.loading") }}</div>
|
||||
</div>
|
||||
<div v-if="queryResult" class="mt-3 overflow-hidden rounded-md border">
|
||||
<div class="border-b bg-muted/20 px-3 py-2 text-sm font-medium">
|
||||
{{ t("consul.ui.queryExecutionResult", { count: queryResult.Nodes.length, datacenter: queryResult.Datacenter || "-" }) }}<span v-if="queryResult.DNS?.TTL" class="ml-2 text-xs font-normal text-muted-foreground">DNS TTL {{ queryResult.DNS.TTL }}</span>
|
||||
</div>
|
||||
<div v-for="item in queryResult.Nodes" :key="`${item.Node.Node}:${item.Service.ID}`" class="grid grid-cols-[minmax(8rem,1fr)_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-3 border-b px-3 py-2 text-sm last:border-b-0">
|
||||
<span class="truncate">{{ item.Node.Node }}</span
|
||||
><span class="truncate font-mono text-xs">{{ item.Service.ID }}</span
|
||||
><span class="truncate font-mono text-xs">{{ queryEndpoint(item) }}</span>
|
||||
</div>
|
||||
<div v-if="queryResult.Nodes.length === 0" class="px-3 py-6 text-center text-sm text-muted-foreground">{{ t("consul.ui.noQueryNodes") }}</div>
|
||||
</div>
|
||||
<div v-if="queryDetails" class="mt-3">
|
||||
<div class="mb-1 text-xs font-medium text-muted-foreground">{{ t("consul.ui.queryExplanation") }}</div>
|
||||
<pre class="max-h-64 overflow-auto rounded-md border bg-muted/20 p-3 text-xs">{{ JSON.stringify(queryDetails, null, 2) }}</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="events" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<p class="mb-3 text-sm text-muted-foreground">{{ t("consul.ui.eventsDescription") }}</p>
|
||||
<div v-if="capabilities && capabilities.events !== 'supported'" class="mb-3 rounded-md border p-3 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capabilities.events) }) }}</div>
|
||||
<div v-if="eventError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ eventError }}</div>
|
||||
<div v-if="canFireEvents" class="mb-3 rounded-md border p-3">
|
||||
<div class="grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,2fr)_auto] gap-2">
|
||||
<Input v-model="eventName" :placeholder="t('consul.ui.eventName')" />
|
||||
<Input v-model="eventPayload" :placeholder="t('consul.ui.payload')" />
|
||||
<Button size="sm" :disabled="eventBusy || !eventValid" @click="fireEvent"><Play class="mr-1 h-3.5 w-3.5" />{{ t("consul.ui.fire") }}</Button>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||
<Input v-model="eventNodeFilter" :placeholder="t('consul.ui.eventNodeFilter')" />
|
||||
<Input v-model="eventServiceFilter" :placeholder="t('consul.ui.eventServiceFilter')" />
|
||||
<Input v-model="eventTagFilter" :placeholder="t('consul.ui.eventTagFilter')" />
|
||||
</div>
|
||||
<div class="mt-2 text-xs" :class="eventPayloadBytes > 100 ? 'text-destructive' : 'text-muted-foreground'">{{ t("consul.ui.eventPayloadBytes", { count: eventPayloadBytes }) }}</div>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<div v-for="event in events" :key="event.ID" class="border-b px-3 py-2 last:border-b-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{{ event.Name }}</span
|
||||
><span class="text-xs text-muted-foreground">LTime {{ event.LTime }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span v-if="event.NodeFilter">{{ t("consul.ui.node") }}: {{ event.NodeFilter }}</span
|
||||
><span v-if="event.ServiceFilter">{{ t("consul.ui.service") }}: {{ event.ServiceFilter }}</span
|
||||
><span v-if="event.TagFilter">{{ t("consul.ui.tags") }}: {{ event.TagFilter }}</span>
|
||||
</div>
|
||||
<div v-if="event.Payload" class="mt-1 break-all rounded bg-muted/30 px-2 py-1 font-mono text-xs">{{ decodeEventPayload(event.Payload) }}</div>
|
||||
</div>
|
||||
<div v-if="!eventLoading && eventAvailable && events.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noEvents") }}</div>
|
||||
<div v-if="eventLoading" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.loading") }}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="coordinates" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<p class="mb-3 text-sm text-muted-foreground">{{ t("consul.ui.coordinatesDescription") }}</p>
|
||||
<div v-if="capabilities && capabilities.coordinates !== 'supported'" class="mb-3 rounded-md border p-3 text-sm text-muted-foreground">{{ t("consul.ui.capability", { status: capabilityLabel(capabilities.coordinates) }) }}</div>
|
||||
<div v-if="coordinateError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ coordinateError }}</div>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<div v-if="coordinates.length" class="grid grid-cols-[minmax(10rem,1.5fr)_minmax(7rem,1fr)_repeat(3,minmax(6rem,1fr))] gap-3 border-b bg-muted/20 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{{ t("consul.ui.node") }}</span
|
||||
><span>{{ t("consul.ui.segment") }}</span
|
||||
><span>{{ t("consul.ui.error") }}</span
|
||||
><span>{{ t("consul.ui.adjustment") }}</span
|
||||
><span>{{ t("consul.ui.height") }}</span>
|
||||
</div>
|
||||
<div v-for="item in coordinates" :key="`${item.Node}:${item.Segment}`" class="grid grid-cols-[minmax(10rem,1.5fr)_minmax(7rem,1fr)_repeat(3,minmax(6rem,1fr))] gap-3 border-b px-3 py-2 text-sm last:border-b-0">
|
||||
<span class="truncate font-medium">{{ item.Node }}</span
|
||||
><span class="truncate text-muted-foreground">{{ item.Segment || t("consul.ui.default") }}</span
|
||||
><span class="font-mono text-xs">{{ item.Coord.Error }}</span
|
||||
><span class="font-mono text-xs">{{ item.Coord.Adjustment }}</span
|
||||
><span class="font-mono text-xs">{{ item.Coord.Height }}</span>
|
||||
</div>
|
||||
<div v-if="!coordinateLoading && coordinateAvailable && coordinates.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.noCoordinates") }}</div>
|
||||
<div v-if="coordinateLoading" class="px-3 py-8 text-center text-sm text-muted-foreground">{{ t("consul.ui.loading") }}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Activity, Boxes, KeyRound, Layers3, Network, RefreshCcw, ShieldCheck, Timer, Wrench, Gauge } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulCapabilities } from "@/types/consul";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { consulMeshWorkspaceVisible } from "@/lib/consul/meshVisibility";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const ConsulKeyBrowser = defineAsyncComponent(() => import("@/components/consul/ConsulKeyBrowser.vue"));
|
||||
const ConsulServices = defineAsyncComponent(() => import("@/components/consul/ConsulServices.vue"));
|
||||
const ConsulHealth = defineAsyncComponent(() => import("@/components/consul/ConsulHealth.vue"));
|
||||
const ConsulSessions = defineAsyncComponent(() => import("@/components/consul/ConsulSessions.vue"));
|
||||
const ConsulAcl = defineAsyncComponent(() => import("@/components/consul/ConsulAcl.vue"));
|
||||
const ConsulScope = defineAsyncComponent(() => import("@/components/consul/ConsulScope.vue"));
|
||||
const ConsulMesh = defineAsyncComponent(() => import("@/components/consul/ConsulMesh.vue"));
|
||||
const ConsulTools = defineAsyncComponent(() => import("@/components/consul/ConsulTools.vue"));
|
||||
const ConsulOperator = defineAsyncComponent(() => import("@/components/consul/ConsulOperator.vue"));
|
||||
|
||||
type WorkspaceTab = "kv" | "services" | "health" | "sessions" | "acl" | "scope" | "mesh" | "tools" | "operator";
|
||||
type Refreshable = { refresh?: () => boolean | Promise<unknown>; focusSearch?: () => boolean };
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const connectionStore = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const { t } = useI18n();
|
||||
const activeTab = ref<WorkspaceTab>("kv");
|
||||
const keyRef = ref<Refreshable>();
|
||||
const servicesRef = ref<Refreshable>();
|
||||
const healthRef = ref<Refreshable>();
|
||||
const sessionsRef = ref<Refreshable>();
|
||||
const aclRef = ref<Refreshable>();
|
||||
const scopeRef = ref<Refreshable>();
|
||||
const meshRef = ref<Refreshable>();
|
||||
const toolsRef = ref<Refreshable>();
|
||||
const operatorRef = ref<Refreshable>();
|
||||
const capabilities = ref<ConsulCapabilities | null>(null);
|
||||
const discoveredDatacenters = ref<string[]>([]);
|
||||
const scopeRevision = ref(0);
|
||||
const scopeOpen = ref(false);
|
||||
const scopeSaving = ref(false);
|
||||
const scopeError = ref("");
|
||||
const scopeDraft = ref({ datacenter: "", partition: "", namespace: "" });
|
||||
const pendingSearchFocus = ref(false);
|
||||
let contextSequence = 0;
|
||||
const activeScope = computed(() => {
|
||||
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
return {
|
||||
datacenter: String(config.datacenter || config.consulDatacenter || config.consul_datacenter || ""),
|
||||
partition: String(config.partition || config.consulPartition || config.consul_partition || ""),
|
||||
namespace: String(config.namespace || config.consulNamespace || config.consul_namespace || ""),
|
||||
};
|
||||
});
|
||||
const datacenterOptions = computed(() => [...new Set([activeScope.value.datacenter, capabilities.value?.datacenter || "", ...discoveredDatacenters.value].map((value) => value.trim()).filter(Boolean))]);
|
||||
const resolvedDatacenter = computed(() => activeScope.value.datacenter || capabilities.value?.datacenter || datacenterOptions.value[0] || "-");
|
||||
const enterpriseScopeAvailable = computed(() => capabilities.value?.namespaces === "supported" || capabilities.value?.partitions === "supported");
|
||||
const aclVisible = computed(() => capabilities.value?.acl !== "disabled");
|
||||
const scopeTabVisible = computed(() => enterpriseScopeAvailable.value);
|
||||
const meshVisible = computed(() => consulMeshWorkspaceVisible(connectionStore.getConfig(props.connectionId)?.external_config));
|
||||
const canChangeScope = computed(() => enterpriseScopeAvailable.value || datacenterOptions.value.length > 1);
|
||||
const scopeLabel = computed(() => (enterpriseScopeAvailable.value ? `${resolvedDatacenter.value} / ${activeScope.value.partition || t("consul.ui.default")} / ${activeScope.value.namespace || t("consul.ui.default")}` : resolvedDatacenter.value));
|
||||
const operatorVisible = computed(() => {
|
||||
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
||||
const config = external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
||||
const capability = capabilities.value;
|
||||
const available = capability && [capability.operatorAutopilot, capability.operatorRaft, capability.operatorKeyring, capability.operatorUsage, capability.operatorLicense, capability.audit].some((status) => status === "supported");
|
||||
return Boolean(config.consulOperatorVisible) && Boolean(available);
|
||||
});
|
||||
function activeHandle() {
|
||||
return { kv: keyRef.value, services: servicesRef.value, health: healthRef.value, sessions: sessionsRef.value, acl: aclRef.value, scope: scopeRef.value, mesh: meshRef.value, tools: toolsRef.value, operator: operatorRef.value }[activeTab.value];
|
||||
}
|
||||
async function loadWorkspaceContext() {
|
||||
const sequence = ++contextSequence;
|
||||
const connectionId = props.connectionId;
|
||||
const [capabilityResult, datacenterResult] = await Promise.allSettled([api.consulCapabilities(connectionId), api.consulCatalogDatacenters(connectionId)]);
|
||||
if (sequence !== contextSequence || connectionId !== props.connectionId) return;
|
||||
capabilities.value = capabilityResult.status === "fulfilled" ? capabilityResult.value : null;
|
||||
discoveredDatacenters.value = datacenterResult.status === "fulfilled" ? [...new Set(datacenterResult.value.map((value) => value.trim()).filter(Boolean))] : [];
|
||||
}
|
||||
function openScopeSwitcher() {
|
||||
scopeDraft.value = {
|
||||
datacenter: resolvedDatacenter.value === "-" ? "" : resolvedDatacenter.value,
|
||||
partition: activeScope.value.partition,
|
||||
namespace: activeScope.value.namespace,
|
||||
};
|
||||
scopeError.value = "";
|
||||
scopeOpen.value = true;
|
||||
}
|
||||
async function applyScope() {
|
||||
const config = connectionStore.getConfig(props.connectionId);
|
||||
if (!config) return;
|
||||
scopeSaving.value = true;
|
||||
scopeError.value = "";
|
||||
const nextScope = {
|
||||
datacenter: scopeDraft.value.datacenter.trim(),
|
||||
partition: scopeDraft.value.partition.trim(),
|
||||
namespace: scopeDraft.value.namespace.trim(),
|
||||
};
|
||||
try {
|
||||
consulStore.bindConnection(props.connectionId, activeScope.value);
|
||||
consulStore.switchScope(nextScope);
|
||||
const currentExternal = config.external_config;
|
||||
const external = currentExternal && typeof currentExternal === "object" && !Array.isArray(currentExternal) ? ({ ...currentExternal } as Record<string, unknown>) : {};
|
||||
external.datacenter = nextScope.datacenter || undefined;
|
||||
external.partition = nextScope.partition || undefined;
|
||||
external.namespace = nextScope.namespace || undefined;
|
||||
await connectionStore.updateConnection({ ...config, external_config: external });
|
||||
scopeRevision.value += 1;
|
||||
scopeOpen.value = false;
|
||||
await loadWorkspaceContext();
|
||||
} catch (error) {
|
||||
scopeError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
scopeSaving.value = false;
|
||||
}
|
||||
}
|
||||
function focusSearch() {
|
||||
activeTab.value = "kv";
|
||||
pendingSearchFocus.value = true;
|
||||
void nextTick(() => {
|
||||
pendingSearchFocus.value = false;
|
||||
keyRef.value?.focusSearch?.();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
function refresh() {
|
||||
const handle = activeHandle();
|
||||
if (!handle?.refresh) return false;
|
||||
void handle.refresh();
|
||||
return true;
|
||||
}
|
||||
watch(keyRef, (value) => {
|
||||
if (value && pendingSearchFocus.value)
|
||||
void nextTick(() => {
|
||||
pendingSearchFocus.value = false;
|
||||
value.focusSearch?.();
|
||||
});
|
||||
});
|
||||
watch([aclVisible, scopeTabVisible, meshVisible], () => {
|
||||
if (activeTab.value === "acl" && !aclVisible.value) activeTab.value = "kv";
|
||||
if (activeTab.value === "scope" && !scopeTabVisible.value) activeTab.value = "kv";
|
||||
if (activeTab.value === "mesh" && !meshVisible.value) activeTab.value = "kv";
|
||||
});
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
consulStore.bindConnection(props.connectionId, activeScope.value);
|
||||
void loadWorkspaceContext();
|
||||
},
|
||||
);
|
||||
onMounted(() => {
|
||||
consulStore.bindConnection(props.connectionId, activeScope.value);
|
||||
void loadWorkspaceContext();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
contextSequence += 1;
|
||||
});
|
||||
defineExpose({ focusSearch, refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs v-model="activeTab" class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div class="flex h-11 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-hidden">
|
||||
<TabsList class="h-8 w-max min-w-max justify-start bg-muted/60 p-0.5">
|
||||
<TabsTrigger value="kv" class="h-7 flex-none gap-1.5 px-3 text-xs"><KeyRound class="h-3.5 w-3.5" />{{ t("consul.workspace.kv") }}</TabsTrigger>
|
||||
<TabsTrigger value="services" class="h-7 flex-none gap-1.5 px-3 text-xs"><Boxes class="h-3.5 w-3.5" />{{ t("consul.workspace.services") }}</TabsTrigger>
|
||||
<TabsTrigger value="health" class="h-7 flex-none gap-1.5 px-3 text-xs"><Activity class="h-3.5 w-3.5" />{{ t("consul.workspace.health") }}</TabsTrigger>
|
||||
<TabsTrigger value="sessions" class="h-7 flex-none gap-1.5 px-3 text-xs"><Timer class="h-3.5 w-3.5" />{{ t("consul.workspace.sessions") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="aclVisible" value="acl" class="h-7 flex-none gap-1.5 px-3 text-xs"><ShieldCheck class="h-3.5 w-3.5" />{{ t("consul.workspace.acl") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="scopeTabVisible" value="scope" class="h-7 flex-none gap-1.5 px-3 text-xs"><Layers3 class="h-3.5 w-3.5" />{{ t("consul.workspace.scope") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="meshVisible" value="mesh" class="h-7 flex-none gap-1.5 px-3 text-xs"><Network class="h-3.5 w-3.5" />{{ t("consul.workspace.mesh") }}</TabsTrigger>
|
||||
<TabsTrigger value="tools" class="h-7 flex-none gap-1.5 px-3 text-xs"><Wrench class="h-3.5 w-3.5" />{{ t("consul.workspace.tools") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="operatorVisible" value="operator" class="h-7 flex-none gap-1.5 px-3 text-xs"><Gauge class="h-3.5 w-3.5" />{{ t("consul.workspace.operator") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button v-if="canChangeScope" variant="ghost" size="sm" class="h-7 max-w-64 truncate px-2 font-mono text-[11px]" :title="t('consul.ui.scope')" @click="openScopeSwitcher">{{ scopeLabel }}</Button>
|
||||
<span v-else class="max-w-64 truncate px-2 font-mono text-[11px]" :title="t('consul.ui.datacenter')">{{ scopeLabel }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('consul.ui.refresh')" @click="refresh"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div :key="`${props.connectionId}:${scopeRevision}`" class="contents">
|
||||
<TabsContent value="kv" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulKeyBrowser ref="keyRef" :connection-id="props.connectionId" /></TabsContent>
|
||||
<TabsContent value="services" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulServices ref="servicesRef" :connection-id="props.connectionId" /></TabsContent>
|
||||
<TabsContent value="health" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulHealth ref="healthRef" :connection-id="props.connectionId" /></TabsContent>
|
||||
<TabsContent value="sessions" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulSessions ref="sessionsRef" :connection-id="props.connectionId" /></TabsContent>
|
||||
<TabsContent v-if="aclVisible" value="acl" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulAcl ref="aclRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
<TabsContent v-if="scopeTabVisible" value="scope" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulScope ref="scopeRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
<TabsContent v-if="meshVisible" value="mesh" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulMesh ref="meshRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
<TabsContent value="tools" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulTools ref="toolsRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
<TabsContent v-if="operatorVisible" value="operator" class="m-0 min-h-0 flex-1 overflow-hidden"><ConsulOperator ref="operatorRef" :connection-id="props.connectionId" :capabilities="capabilities" /></TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
<Dialog v-model:open="scopeOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("consul.ui.scope") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div class="grid gap-3 py-2">
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("consul.ui.datacenter") }}</Label>
|
||||
<Select v-if="datacenterOptions.length > 1" v-model="scopeDraft.datacenter"
|
||||
><SelectTrigger><SelectValue /></SelectTrigger
|
||||
><SelectContent
|
||||
><SelectItem v-for="datacenter in datacenterOptions" :key="datacenter" :value="datacenter">{{ datacenter }}</SelectItem></SelectContent
|
||||
></Select
|
||||
>
|
||||
<Input v-else v-model="scopeDraft.datacenter" readonly />
|
||||
</div>
|
||||
<template v-if="enterpriseScopeAvailable">
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("consul.ui.adminPartition") }}</Label
|
||||
><Input v-model="scopeDraft.partition" :placeholder="t('consul.ui.default')" />
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("consul.ui.namespace") }}</Label
|
||||
><Input v-model="scopeDraft.namespace" :placeholder="t('consul.ui.default')" />
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="scopeError" class="text-sm text-destructive">{{ scopeError }}</p>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="scopeSaving" @click="scopeOpen = false">{{ t("consul.ui.cancel") }}</Button
|
||||
><Button :disabled="scopeSaving" @click="applyScope">{{ t("consul.ui.applyScope") }}</Button></DialogFooter
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ListResult = { items: Array<Record<string, unknown>> };
|
||||
type PendingList = {
|
||||
kind: string;
|
||||
resolve: (value: ListResult) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pendingLists: [] as PendingList[],
|
||||
referencesError: "",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
consulAclList: vi.fn(
|
||||
(_connectionId: string, kind: string) =>
|
||||
new Promise<ListResult>((resolve, reject) => {
|
||||
mocks.pendingLists.push({ kind, resolve, reject });
|
||||
}),
|
||||
),
|
||||
consulAclReferences: vi.fn(async () => {
|
||||
if (mocks.referencesError) throw new Error(mocks.referencesError);
|
||||
return { tokenAccessorIds: [], roleIds: [], bindingRuleIds: [], complete: true };
|
||||
}),
|
||||
consulAclGet: vi.fn(),
|
||||
consulAclApply: vi.fn(),
|
||||
consulAclTokenClone: vi.fn(),
|
||||
consulAclDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ getConfig: () => ({ read_only: false }) }),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConsulAcl from "../ConsulAcl.vue";
|
||||
|
||||
const capabilities = {
|
||||
acl: "supported",
|
||||
authMethods: "supported",
|
||||
bindingRules: "supported",
|
||||
templatedPolicies: "supported",
|
||||
};
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveList(kind: string, items: Array<Record<string, unknown>>) {
|
||||
const index = mocks.pendingLists.findIndex((request) => request.kind === kind);
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
const [request] = mocks.pendingLists.splice(index, 1);
|
||||
request.resolve({ items });
|
||||
}
|
||||
|
||||
function clickButton(host: HTMLElement, label: string) {
|
||||
const button = Array.from(host.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === label);
|
||||
expect(button).toBeTruthy();
|
||||
button!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
mocks.pendingLists.splice(0);
|
||||
mocks.referencesError = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConsulAcl interactions", () => {
|
||||
it("ignores a stale ACL list response after switching resource kinds", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulAcl, { connectionId: "connection-1", capabilities });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
resolveList("token", []);
|
||||
await settle();
|
||||
clickButton(host, "consul.ui.policies");
|
||||
await settle();
|
||||
clickButton(host, "consul.ui.roles");
|
||||
await settle();
|
||||
|
||||
resolveList("role", [{ ID: "role-1", Name: "current-role" }]);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("current-role");
|
||||
|
||||
resolveList("policy", [{ ID: "policy-1", Name: "stale-policy" }]);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("current-role");
|
||||
expect(host.textContent).not.toContain("stale-policy");
|
||||
});
|
||||
|
||||
it("shows an ACL operation error instead of failing silently", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulAcl, { connectionId: "connection-1", capabilities });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
resolveList("token", [{ AccessorID: "token-1", Description: "test-token" }]);
|
||||
await settle();
|
||||
mocks.referencesError = "reference lookup failed";
|
||||
const deleteButton = host.querySelector('button[title="consul.ui.deleteAndInspectReferences"]');
|
||||
expect(deleteButton).toBeTruthy();
|
||||
deleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).toContain("reference lookup failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type PendingList = {
|
||||
kind: string;
|
||||
resolve: (value: { items: Array<Record<string, unknown>> }) => void;
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({ pendingLists: [] as PendingList[] }));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
consulEnterpriseList: vi.fn(
|
||||
(_connectionId: string, kind: string) =>
|
||||
new Promise((resolve) => {
|
||||
mocks.pendingLists.push({ kind, resolve });
|
||||
}),
|
||||
),
|
||||
consulEnterpriseApply: vi.fn(),
|
||||
consulEnterpriseImpact: vi.fn(),
|
||||
consulEnterpriseDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: () => ({ read_only: false, external_config: { datacenter: "dc1" } }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConsulScope from "../ConsulScope.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveList(kind: string, items: Array<Record<string, unknown>>) {
|
||||
const index = mocks.pendingLists.findIndex((request) => request.kind === kind);
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
const [request] = mocks.pendingLists.splice(index, 1);
|
||||
request.resolve({ items });
|
||||
}
|
||||
|
||||
function activateTab(host: HTMLElement, label: string) {
|
||||
const button = Array.from(host.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === label);
|
||||
expect(button).toBeTruthy();
|
||||
button!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
mocks.pendingLists.splice(0);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConsulScope interactions", () => {
|
||||
it("does not let a stale partition request overwrite the active namespace list", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulScope, {
|
||||
connectionId: "connection-1",
|
||||
capabilities: { namespaces: "supported", partitions: "supported" },
|
||||
});
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
resolveList("namespace", []);
|
||||
await settle();
|
||||
activateTab(host, "consul.workspace.partitions");
|
||||
await settle();
|
||||
activateTab(host, "consul.ui.namespaces");
|
||||
await settle();
|
||||
|
||||
resolveList("namespace", [{ Name: "current-namespace" }]);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("current-namespace");
|
||||
|
||||
resolveList("partition", [{ Name: "stale-partition" }]);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("current-namespace");
|
||||
expect(host.textContent).not.toContain("stale-partition");
|
||||
});
|
||||
});
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const agentChecks: Record<string, Record<string, unknown>> = {};
|
||||
return {
|
||||
connectionConfig: { base_url: "http://127.0.0.1:8500", external_config: {} } as Record<string, unknown>,
|
||||
agentChecks,
|
||||
serviceMaintenance: vi.fn(async (_connectionId: string, id: string, enable: boolean) => {
|
||||
const checkId = `_service_maintenance:${id}`;
|
||||
if (enable) {
|
||||
agentChecks[checkId] = {
|
||||
Node: "node-1",
|
||||
CheckID: checkId,
|
||||
Name: "Service Maintenance Mode",
|
||||
Status: "critical",
|
||||
Notes: "test",
|
||||
Output: "",
|
||||
ServiceID: id,
|
||||
ServiceName: "dbx-demo-api",
|
||||
ServiceTags: ["demo"],
|
||||
Type: "maintenance",
|
||||
ExposedPort: 0,
|
||||
Definition: {},
|
||||
CreateIndex: 0,
|
||||
ModifyIndex: 0,
|
||||
Maintenance: true,
|
||||
};
|
||||
} else {
|
||||
delete agentChecks[checkId];
|
||||
}
|
||||
return { target: { node: "node-1", address: "127.0.0.1" } };
|
||||
}),
|
||||
catalogServiceNodes: vi.fn(async (_connectionId: string, service: string) => ({
|
||||
items: [
|
||||
{
|
||||
ID: "node-1",
|
||||
Node: "node-1",
|
||||
Address: "127.0.0.1",
|
||||
Datacenter: "dc1",
|
||||
TaggedAddresses: {},
|
||||
NodeMeta: { zone: "demo-zone" },
|
||||
ServiceKind: "",
|
||||
ServiceID: `${service}-1`,
|
||||
ServiceName: service,
|
||||
ServiceTags: ["demo"],
|
||||
ServiceAddress: "127.0.0.1",
|
||||
ServicePort: 19080,
|
||||
ServiceMeta: { owner: "DBX" },
|
||||
ServiceTaggedAddresses: {},
|
||||
ServiceWeights: { Passing: 1, Warning: 1 },
|
||||
CreateIndex: 1,
|
||||
ModifyIndex: 1,
|
||||
},
|
||||
],
|
||||
metadata: { index: "1", knownLeader: true, lastContact: 0, queryBackend: null },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
consulCatalogServices: vi.fn(async () => ({
|
||||
items: { consul: [], "dbx-demo-api": ["v1", "demo", "api"] },
|
||||
metadata: { index: "1", knownLeader: true, lastContact: 0, queryBackend: null },
|
||||
})),
|
||||
consulCatalogNodes: vi.fn(async () => ({
|
||||
items: [{ ID: "node-1", Node: "node-1", Address: "127.0.0.1", Datacenter: "dc1", TaggedAddresses: {}, NodeMeta: {}, CreateIndex: 1, ModifyIndex: 1 }],
|
||||
metadata: { index: "1", knownLeader: true, lastContact: 0, queryBackend: null },
|
||||
})),
|
||||
consulCatalogServiceNodes: mocks.catalogServiceNodes,
|
||||
consulCatalogNodeServices: vi.fn(async () => ({
|
||||
items: {
|
||||
Node: { ID: "node-1", Node: "node-1", Address: "127.0.0.1", Datacenter: "dc1", TaggedAddresses: {}, NodeMeta: {}, CreateIndex: 1, ModifyIndex: 1 },
|
||||
Services: {
|
||||
"dbx-demo-node-service-1": {
|
||||
ID: "dbx-demo-node-service-1",
|
||||
Service: "dbx-demo-node-service",
|
||||
Tags: ["demo", "node"],
|
||||
Address: "127.0.0.1",
|
||||
TaggedAddresses: { lan: { Address: "127.0.0.1", Port: 19090 } },
|
||||
Meta: { owner: "DBX" },
|
||||
Port: 19090,
|
||||
Weights: { Passing: 2, Warning: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: { index: "1", knownLeader: true, lastContact: 0, queryBackend: null },
|
||||
})),
|
||||
consulAgentSelf: vi.fn(async () => ({ node: "node-1", address: "127.0.0.1", datacenter: "dc1", version: "2.0.2", server: true, revision: null, segment: null })),
|
||||
consulAgentServices: vi.fn(async () => ({
|
||||
"dbx-demo-api-1": { Kind: "", ID: "dbx-demo-api-1", Service: "dbx-demo-api", Tags: ["demo"], Meta: {}, Port: 19080, Address: "127.0.0.1", TaggedAddresses: {}, Weights: { Passing: 1, Warning: 1 }, EnableTagOverride: false, Datacenter: "dc1" },
|
||||
})),
|
||||
consulAgentChecks: vi.fn(async () => ({ ...mocks.agentChecks })),
|
||||
consulAgentService: vi.fn(async (_connectionId: string, id: string) => ({
|
||||
Kind: "",
|
||||
ID: id,
|
||||
Service: "dbx-demo-api",
|
||||
Tags: ["demo"],
|
||||
Meta: { owner: "DBX" },
|
||||
Port: 19080,
|
||||
Address: "127.0.0.1",
|
||||
TaggedAddresses: {},
|
||||
Weights: { Passing: 1, Warning: 1 },
|
||||
EnableTagOverride: false,
|
||||
Datacenter: "dc1",
|
||||
})),
|
||||
consulAgentRegisterService: vi.fn(),
|
||||
consulAgentDeregisterService: vi.fn(),
|
||||
consulAgentServiceMaintenance: mocks.serviceMaintenance,
|
||||
consulDomainWatch: vi.fn(),
|
||||
consulCancelBlocking: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: () => mocks.connectionConfig,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/consulStore", () => ({
|
||||
useConsulStore: () => ({
|
||||
generation: 1,
|
||||
bindConnection: vi.fn(),
|
||||
registerOperation: vi.fn(),
|
||||
completeOperation: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConsulServices from "../ConsulServices.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
mocks.connectionConfig = { base_url: "http://127.0.0.1:8500", external_config: {} };
|
||||
for (const key of Object.keys(mocks.agentChecks)) delete mocks.agentChecks[key];
|
||||
});
|
||||
|
||||
describe("ConsulServices interactions", () => {
|
||||
it("uses a neutral verification state before the Agent identity resolves", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
|
||||
expect(host.textContent).toContain("consul.ui.agentTargetVerifying");
|
||||
expect(host.textContent).not.toContain("consul.ui.agentReadOnly");
|
||||
});
|
||||
|
||||
it("selects a Catalog service and renders its instances on click", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const serviceButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("dbx-demo-api"));
|
||||
expect(serviceButton).toBeTruthy();
|
||||
expect(serviceButton!.textContent).toBe("dbx-demo-api");
|
||||
serviceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector("h2")?.textContent).toContain("dbx-demo-api");
|
||||
expect(host.textContent).toContain("dbx-demo-api-1");
|
||||
expect(mocks.catalogServiceNodes).toHaveBeenLastCalledWith("connection-1", "dbx-demo-api");
|
||||
|
||||
const catalogRow = host.querySelector('tr[role="button"]');
|
||||
expect(catalogRow).toBeTruthy();
|
||||
catalogRow!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(host.textContent).toContain("consul.ui.nodeMeta");
|
||||
expect(host.textContent).toContain("zone=demo-zone");
|
||||
expect(host.textContent).toContain("consul.ui.serviceMeta");
|
||||
expect(catalogRow!.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
catalogRow!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(host.textContent).not.toContain("zone=demo-zone");
|
||||
expect(catalogRow!.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps local service cards interactive while explaining read-only Agent actions", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).toContain("consul.ui.agentReadOnly");
|
||||
expect(host.textContent).toContain("consul.ui.agentWriteDisabledHint");
|
||||
expect(host.textContent).not.toContain("consul.ui.enableMaintenance");
|
||||
|
||||
const localServiceButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("dbx-demo-api-1"));
|
||||
expect(localServiceButton).toBeTruthy();
|
||||
expect(localServiceButton!.hasAttribute("disabled")).toBe(false);
|
||||
localServiceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).toContain("consul.ui.enableTagOverride");
|
||||
expect(host.textContent).toContain("consul.ui.serviceWeights");
|
||||
expect(localServiceButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
localServiceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(host.textContent).not.toContain("consul.ui.enableTagOverride");
|
||||
expect(host.textContent).not.toContain("consul.ui.serviceWeights");
|
||||
expect(localServiceButton!.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows and toggles the current service maintenance state", async () => {
|
||||
mocks.connectionConfig = {
|
||||
base_url: "http://127.0.0.1:8500",
|
||||
external_config: {
|
||||
serverAddr: "http://127.0.0.1:8500",
|
||||
agentTarget: { node: "node-1", address: "127.0.0.1" },
|
||||
},
|
||||
};
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const enableButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("consul.ui.enableMaintenance"));
|
||||
expect(enableButton).toBeTruthy();
|
||||
enableButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(mocks.serviceMaintenance).toHaveBeenLastCalledWith("connection-1", "dbx-demo-api-1", true, "consul.ui.maintenanceReason");
|
||||
expect(host.textContent).toContain("consul.ui.maintenance");
|
||||
const disableButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("consul.ui.disableMaintenance"));
|
||||
expect(disableButton).toBeTruthy();
|
||||
disableButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(mocks.serviceMaintenance).toHaveBeenLastCalledWith("connection-1", "dbx-demo-api-1", false, "consul.ui.maintenanceReason");
|
||||
expect(host.textContent).not.toContain("consul.ui.maintenance");
|
||||
expect(host.textContent).toContain("consul.ui.enableMaintenance");
|
||||
});
|
||||
|
||||
it("expands and collapses full service details in Catalog node view", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulServices, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const nodeTab = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "consul.ui.node");
|
||||
expect(nodeTab).toBeTruthy();
|
||||
nodeTab!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
const nodeServiceButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("dbx-demo-node-service-1"));
|
||||
expect(nodeServiceButton).toBeTruthy();
|
||||
nodeServiceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(host.textContent).toContain("owner=DBX");
|
||||
expect(host.textContent).toContain("consul.ui.serviceWeights");
|
||||
expect(host.textContent).toContain("lan=127.0.0.1:19090");
|
||||
expect(nodeServiceButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
nodeServiceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(host.textContent).not.toContain("lan=127.0.0.1:19090");
|
||||
expect(nodeServiceButton!.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const services = readFileSync(new URL("../ConsulServices.vue", import.meta.url), "utf8");
|
||||
const health = readFileSync(new URL("../ConsulHealth.vue", import.meta.url), "utf8");
|
||||
const overview = readFileSync(new URL("../ConsulOverview.vue", import.meta.url), "utf8");
|
||||
const sessions = readFileSync(new URL("../ConsulSessions.vue", import.meta.url), "utf8");
|
||||
const scope = readFileSync(new URL("../ConsulScope.vue", import.meta.url), "utf8");
|
||||
const keyBrowser = readFileSync(new URL("../ConsulKeyBrowser.vue", import.meta.url), "utf8");
|
||||
const mesh = readFileSync(new URL("../ConsulMesh.vue", import.meta.url), "utf8");
|
||||
const tools = readFileSync(new URL("../ConsulTools.vue", import.meta.url), "utf8");
|
||||
const operator = readFileSync(new URL("../ConsulOperator.vue", import.meta.url), "utf8");
|
||||
const workspace = readFileSync(new URL("../ConsulWorkspace.vue", import.meta.url), "utf8");
|
||||
const kvBrowser = readFileSync(new URL("../../kv/KvKeyBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("Consul Catalog and Health watch views", () => {
|
||||
it("watches both service and node Catalog resources and cancels them on teardown", () => {
|
||||
for (const target of ["catalogServices", "catalogServiceNodes", "catalogNodes", "catalogNodeServices"]) {
|
||||
expect(services).toContain(`kind: "${target}"`);
|
||||
}
|
||||
expect(services).toMatch(/onBeforeUnmount\(\(\) => \{\s*void stopWatches\(\);\s*\}\);/);
|
||||
expect(services).toContain("switchBrowseMode");
|
||||
});
|
||||
|
||||
it("supports state, node, and service Health resources with a scoped watch", () => {
|
||||
for (const target of ["healthState", "healthNode", "healthServiceInstances"]) {
|
||||
expect(health).toContain(`kind: "${target}"`);
|
||||
}
|
||||
expect(health).toContain('browseMode = ref<"state" | "node" | "service">');
|
||||
expect(health).toMatch(/onBeforeUnmount\(\(\) => \{\s*void stopWatch\(\);\s*\}\);/);
|
||||
expect(health).toContain("selectedCheck");
|
||||
expect(health).toContain("selectedCheck.Definition");
|
||||
});
|
||||
|
||||
it("filters and paginates Health results without querying on every target keystroke", () => {
|
||||
for (const state of ["browseNameDraft", "keywordFilter", "statusFilter", "typeFilter"]) {
|
||||
expect(health).toContain(`const ${state} = ref(`);
|
||||
}
|
||||
for (const collection of ["filteredChecks", "filteredLocalChecks", "pagedChecks", "pagedLocalChecks", "pagedServiceInstances"]) {
|
||||
expect(health).toContain(`const ${collection} = computed(`);
|
||||
}
|
||||
expect(health).toContain('v-model="browseNameDraft"');
|
||||
expect(health).toContain('@keydown.enter="applyBrowseTarget"');
|
||||
expect(health).toContain("t('consul.ui.searchHealthChecks')");
|
||||
expect(health).toContain("<ConsulListPagination");
|
||||
expect(health).not.toContain("watch([state, browseMode, browseName]");
|
||||
});
|
||||
|
||||
it("shows Catalog metadata and independent Overview probe states", () => {
|
||||
expect(services).toContain("item.NodeMeta");
|
||||
expect(services).toContain("item.ServiceMeta");
|
||||
expect(services).toContain("nodeServices?.Node.NodeMeta");
|
||||
for (const state of ["loading", "ready", "forbidden", "unsupported", "error"]) {
|
||||
expect(overview).toContain(`"${state}"`);
|
||||
}
|
||||
expect(overview).toContain("probeStates[key]");
|
||||
});
|
||||
|
||||
it("binds Session renew to connection and scope lifecycle and exposes lock indexes", () => {
|
||||
expect(sessions).toContain("connectionActive");
|
||||
expect(sessions).toContain("consulStore.generation");
|
||||
expect(sessions).toContain("currentScopeKey()");
|
||||
expect(sessions).toContain("clearTimeout(renewTimer)");
|
||||
expect(sessions).toContain("item.lockIndex");
|
||||
expect(sessions).toContain("session.NodeChecks");
|
||||
expect(sessions).toContain("session.ServiceChecks");
|
||||
expect(sessions).toContain("!isTtlSession(session)");
|
||||
expect(sessions).toContain("ConsulListPagination");
|
||||
expect(sessions).toContain("filteredSessions");
|
||||
expect(sessions).toContain("await inspectLockKey()");
|
||||
expect(sessions).toContain("expectedModifyIndex: lockInspection.value.modifyIndex");
|
||||
expect(sessions).toContain("heldKeys[session.ID].complete");
|
||||
expect(sessions).toContain("value.items.map(normalizeConsulSession)");
|
||||
expect(sessions).toContain("withUiTimeout(api.consulSessions(props.connectionId))");
|
||||
});
|
||||
|
||||
it("renders the complete Enterprise delete impact inventory", () => {
|
||||
for (const resource of ["kvKeys", "healthChecks", "sessions", "intentions", "peerings", "aclTokens", "aclPolicies", "aclRoles", "aclAuthMethods", "aclBindingRules"]) {
|
||||
expect(scope).toContain(`value.${resource}`);
|
||||
}
|
||||
expect(scope).toContain("impact.filteredByAcls");
|
||||
expect(scope).toContain("impact.unavailableResources");
|
||||
});
|
||||
|
||||
it("offers both Agent service maintenance transitions", () => {
|
||||
expect(services).toContain("maintenance(service.ID, true)");
|
||||
expect(services).toContain("maintenance(service.ID, false)");
|
||||
expect(services).toContain('t("consul.ui.enableMaintenance")');
|
||||
expect(services).toContain('t("consul.ui.disableMaintenance")');
|
||||
expect(services).toContain('v-if="canAgentWrite"');
|
||||
expect(services).toContain('t("consul.ui.agentWriteDisabledHint"');
|
||||
});
|
||||
|
||||
it("keeps Catalog browsing searchable and separates readable Catalog data from Agent actions", () => {
|
||||
expect(services).toContain('const browseFilter = ref("");');
|
||||
expect(services).toContain("const filteredServiceNames");
|
||||
expect(services).toContain("const filteredNodeNames");
|
||||
expect(services).toContain("const localServiceItems");
|
||||
expect(services).toContain("const filteredLocalServiceItems");
|
||||
expect(services).toContain("<Search class=");
|
||||
expect(services).toContain("selectedLocalService?.ID === service.ID");
|
||||
expect(services).toContain('<Button v-for="name in pagedServiceNames"');
|
||||
expect(services).not.toContain("services[name]?.join(', ')");
|
||||
expect(services).toContain('@click="selectService(name)"');
|
||||
expect(services).toContain('@click="selectNode(name)"');
|
||||
expect(services).toContain("Catalog browsing must remain responsive even when an Agent endpoint is slow or unavailable.");
|
||||
expect(services).toContain("void loadAgentData(current);");
|
||||
expect(services).toContain("t('consul.ui.searchAllServices')");
|
||||
expect(services).toContain("t('consul.ui.searchLocalServices')");
|
||||
});
|
||||
|
||||
it("loads Consul tools independently and exposes complete event and result details", () => {
|
||||
expect(tools).toContain('const activeTab = ref<ToolTab>("queries")');
|
||||
for (const loader of ["loadQueries", "loadEvents", "loadCoordinates"]) {
|
||||
expect(tools).toContain(`async function ${loader}()`);
|
||||
}
|
||||
for (const errorState of ["queryError", "eventError", "coordinateError"]) {
|
||||
expect(tools).toContain(`const ${errorState} = ref("")`);
|
||||
}
|
||||
expect(tools).not.toContain("Promise.allSettled");
|
||||
expect(tools).toContain('v-model="eventNodeFilter"');
|
||||
expect(tools).toContain('v-model="eventServiceFilter"');
|
||||
expect(tools).toContain('v-model="eventTagFilter"');
|
||||
expect(tools).toContain("queryResult.Nodes");
|
||||
expect(tools).toContain("item.Coord.Adjustment");
|
||||
expect(tools).toContain("item.Coord.Height");
|
||||
});
|
||||
|
||||
it("paginates every potentially large service collection before rendering", () => {
|
||||
for (const collection of ["pagedServiceNames", "pagedNodeNames", "pagedCatalogInstances", "pagedNodeServices", "pagedLocalServices"]) {
|
||||
expect(services).toContain(`const ${collection} = computed(() => paginateConsulItems(`);
|
||||
expect(services).toContain(`v-for="${collection === "pagedCatalogInstances" ? "item" : collection.includes("Names") ? "name" : "service"} in ${collection}"`);
|
||||
}
|
||||
expect(services).toContain("<ConsulListPagination");
|
||||
expect(services).toContain(':page-size="CONSUL_LIST_PAGE_SIZE"');
|
||||
});
|
||||
|
||||
it("gates Agent writes on an explicit target that matches the Agent node", () => {
|
||||
for (const source of [services, health]) {
|
||||
expect(source).toContain("config.agentTarget || config.agent_target");
|
||||
expect(source).toContain("consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node)");
|
||||
expect(source).toContain(':disabled="!canAgentWrite"');
|
||||
}
|
||||
});
|
||||
|
||||
it("supports exact-Key and prefix KV watches and exposes batch atomicity", () => {
|
||||
expect(keyBrowser).toContain('watchMode = ref<"key" | "prefix">("prefix")');
|
||||
expect(keyBrowser).toContain('prefix: watchMode.value === "prefix"');
|
||||
expect(keyBrowser).toContain(':on-watch-key="watchSelectedKey"');
|
||||
expect(keyBrowser).toContain("transferReport.atomic");
|
||||
expect(keyBrowser).toContain("deleteReport.atomic");
|
||||
expect(keyBrowser).toContain("row.opIndex");
|
||||
});
|
||||
|
||||
it("keeps the Key prefix search first and groups enhanced Consul KV actions after a divider", () => {
|
||||
expect(keyBrowser).toContain("<template #toolbar-trailing>");
|
||||
expect(keyBrowser).not.toContain('v-model="watchPrefix" class="h-7 w-52 text-xs"');
|
||||
expect(kvBrowser).toContain('<slot name="toolbar-trailing" />');
|
||||
expect(kvBrowser).toContain('class="mx-1 h-6 w-px shrink-0 bg-border"');
|
||||
});
|
||||
|
||||
it("keeps exact-Key watch start and stop beside the selected Key", () => {
|
||||
expect(keyBrowser).toContain(":watch-active-key=\"watchRunning && watchMode === 'key' ? watchPrefix : null\"");
|
||||
expect(keyBrowser).toContain('watchMode.value === "key" && watchPrefix.value === route.key');
|
||||
expect(keyBrowser).toContain("await stopWatch();\n return;");
|
||||
});
|
||||
|
||||
it("serializes search progress polling and invalidates stale pollers", () => {
|
||||
expect(keyBrowser).toContain("searchProgressSequence");
|
||||
expect(keyBrowser).toContain("searchProgressTimer = setTimeout");
|
||||
expect(keyBrowser).toContain("sequence === searchProgressSequence");
|
||||
expect(keyBrowser).not.toContain("searchProgressTimer = setInterval");
|
||||
});
|
||||
|
||||
it("uses a compact Session-protection card and a metadata grid for Consul KV details", () => {
|
||||
expect(kvBrowser).toContain('labels.sessionProtected || "Session protected"');
|
||||
expect(kvBrowser).toContain('labels.sessionProtectedHint || "Editing and deletion are disabled to protect this distributed lock."');
|
||||
expect(kvBrowser).toContain("copyText(selectedMetadata?.session)");
|
||||
expect(kvBrowser).toContain('<dl class="grid grid-cols-2 divide-x divide-y sm:grid-cols-3">');
|
||||
expect(kvBrowser).not.toContain('["Session", metadata?.session]');
|
||||
});
|
||||
|
||||
it("exports the current Consul full-text search result set", () => {
|
||||
expect(keyBrowser).toContain("async function exportSearchResults()");
|
||||
expect(keyBrowser).toContain('format: "dbx-consul-kv-search-results"');
|
||||
expect(keyBrowser).toContain('t("consul.tools.exportSearchResults")');
|
||||
});
|
||||
|
||||
it("carries full-text match metadata into the selected Consul KV detail", () => {
|
||||
expect(keyBrowser).toContain("const searchHighlight = ref<{");
|
||||
expect(keyBrowser).toContain("matchesKey: result.matchesKey");
|
||||
expect(keyBrowser).toContain(':search-highlight="searchHighlight"');
|
||||
});
|
||||
|
||||
it("offers the same server-backed prefix completion for export, migration, and prefix deletion", () => {
|
||||
expect(keyBrowser).toContain('type PrefixSuggestionTarget = "export" | "migration" | "delete"');
|
||||
expect(keyBrowser).toContain("const prefixSuggestionDebounceMs = 300;");
|
||||
expect(keyBrowser).toContain("function schedulePrefixSuggestions(target: PrefixSuggestionTarget, value: string)");
|
||||
expect(keyBrowser).toContain("schedulePrefixSuggestions(target, suggestion.key);");
|
||||
expect(keyBrowser).toContain('event.key !== "Enter" && event.key !== "Tab"');
|
||||
expect(keyBrowser).toContain("prefixSuggestionIndex.value >= 0 ? prefixSuggestionIndex.value : 0");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('export'");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('migration'");
|
||||
expect(keyBrowser).toContain("@input=\"schedulePrefixSuggestions('delete'");
|
||||
expect(keyBrowser).toContain("@keydown=\"onPrefixSuggestionKeydown('migration', $event)\"");
|
||||
expect(keyBrowser).toContain("@keydown=\"onPrefixSuggestionKeydown('delete', $event)\"");
|
||||
expect(keyBrowser).toContain('<PopoverContent align="start" side="bottom" :collision-padding="12"');
|
||||
expect(keyBrowser).toContain("max-h-[var(--reka-popover-content-available-height)]");
|
||||
});
|
||||
|
||||
it("marks the specific locked Key and Session in a prefix-delete preview", () => {
|
||||
expect(keyBrowser).toContain("'session' in row && row.session ? 'bg-destructive/5' : ''");
|
||||
expect(keyBrowser).toContain('v-if="\'session\' in row && row.session" variant="destructive"');
|
||||
expect(keyBrowser).toContain('t("consul.ui.sessionValue", { id: row.session })');
|
||||
});
|
||||
|
||||
it("shows the server-provided reason when a prefix-delete operation fails", () => {
|
||||
expect(keyBrowser).toContain("'message' in row && row.message");
|
||||
expect(keyBrowser).toContain(':title="row.message"');
|
||||
});
|
||||
|
||||
it("opens the safe prefix-delete preview from a Consul directory context menu", () => {
|
||||
expect(keyBrowser).toContain(':on-delete-prefix="openDeletePrefix"');
|
||||
expect(keyBrowser).toContain('function openDeletePrefix(prefix = "")');
|
||||
expect(keyBrowser).not.toContain('@click="openDeletePrefix"');
|
||||
expect(kvBrowser).toContain("if (nodeIsExpandable(node) && props.onDeletePrefix)");
|
||||
expect(kvBrowser).toContain("action: () => props.onDeletePrefix?.(nodePath(node))");
|
||||
});
|
||||
|
||||
it("preserves Exported Services CAS state and capability-gates advanced writes", () => {
|
||||
expect(mesh).toContain('consulMeshConfigList(props.connectionId, "exported-services")');
|
||||
expect(mesh).toContain("editingExported.value?.modifyIndex || 0");
|
||||
expect(tools).toContain("canWriteQueries");
|
||||
expect(tools).toContain("canFireEvents");
|
||||
expect(operator).toContain("keyringWriteVisible");
|
||||
expect(operator).toContain("props.capabilities?.operatorKeyring");
|
||||
});
|
||||
|
||||
it("keeps workspace actions fixed while only the tab strip scrolls horizontally", () => {
|
||||
expect(workspace).toContain("min-w-0 flex-1 overflow-x-auto overflow-y-hidden");
|
||||
expect(workspace).toContain("h-8 w-max min-w-max justify-start");
|
||||
expect(workspace).toContain('class="h-7 flex-none gap-1.5 px-3 text-xs"');
|
||||
expect(workspace).toContain("flex h-11 shrink-0 items-center gap-2 border-b px-3");
|
||||
expect(workspace).toContain('class="flex shrink-0 items-center gap-1"');
|
||||
});
|
||||
|
||||
it("starts with KV and keeps the cluster overview as a sidebar surface", () => {
|
||||
expect(workspace).toContain('const activeTab = ref<WorkspaceTab>("kv");');
|
||||
expect(workspace).not.toContain('<TabsTrigger value="overview"');
|
||||
expect(workspace).not.toContain('<TabsContent value="overview"');
|
||||
});
|
||||
|
||||
it("shows the real datacenter by default and gates advanced scope controls by capability", () => {
|
||||
expect(workspace).toContain("const resolvedDatacenter = computed(() => activeScope.value.datacenter || capabilities.value?.datacenter");
|
||||
expect(workspace).toContain('const enterpriseScopeAvailable = computed(() => capabilities.value?.namespaces === "supported" || capabilities.value?.partitions === "supported");');
|
||||
expect(workspace).toContain('v-if="enterpriseScopeAvailable"');
|
||||
expect(workspace).toContain('v-if="datacenterOptions.length > 1" v-model="scopeDraft.datacenter"');
|
||||
});
|
||||
|
||||
it("hides only disabled ACLs and Enterprise-only scope tabs", () => {
|
||||
expect(workspace).toContain('const aclVisible = computed(() => capabilities.value?.acl !== "disabled");');
|
||||
expect(workspace).toContain("const scopeTabVisible = computed(() => enterpriseScopeAvailable.value);");
|
||||
expect(workspace).toContain('v-if="aclVisible" value="acl"');
|
||||
expect(workspace).toContain('v-if="scopeTabVisible" value="scope"');
|
||||
expect(workspace).toContain('if (activeTab.value === "acl" && !aclVisible.value) activeTab.value = "kv";');
|
||||
});
|
||||
});
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listQueries: vi.fn(async () => []),
|
||||
updateConnection: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function mockWorkspaceChild(refresh = vi.fn(() => true)) {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
setup(_props: unknown, { expose }: { expose: (value: { refresh: () => boolean }) => void }) {
|
||||
expose({ refresh });
|
||||
return () => "mock-consul-child";
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/components/consul/ConsulKeyBrowser.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulServices.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulHealth.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulSessions.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulAcl.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulScope.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulMesh.vue", () => mockWorkspaceChild());
|
||||
vi.mock("@/components/consul/ConsulOperator.vue", () => mockWorkspaceChild());
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
consulCapabilities: vi.fn(async () => ({
|
||||
acl: "supported",
|
||||
authMethods: "supported",
|
||||
bindingRules: "supported",
|
||||
templatedPolicies: "supported",
|
||||
namespaces: "supported",
|
||||
partitions: "supported",
|
||||
configEntries: "supported",
|
||||
intentions: "supported",
|
||||
peering: "supported",
|
||||
exportedServices: "supported",
|
||||
preparedQueries: "supported",
|
||||
events: "supported",
|
||||
coordinates: "supported",
|
||||
operatorAutopilot: "supported",
|
||||
operatorRaft: "supported",
|
||||
operatorKeyring: "supported",
|
||||
operatorUsage: "supported",
|
||||
operatorLicense: "supported",
|
||||
audit: "supported",
|
||||
datacenter: "dc1",
|
||||
})),
|
||||
consulCatalogDatacenters: vi.fn(async () => ["dc1", "dc2"]),
|
||||
consulPreparedQueryList: mocks.listQueries,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: () => ({
|
||||
id: "connection-1",
|
||||
external_config: { datacenter: "dc1", consulMeshVisible: true, consulOperatorVisible: true },
|
||||
}),
|
||||
updateConnection: mocks.updateConnection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/consulStore", () => ({
|
||||
useConsulStore: () => ({ bindConnection: vi.fn(), switchScope: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConsulWorkspace from "../ConsulWorkspace.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConsulWorkspace interactions", () => {
|
||||
it("renders a working datacenter selector when multiple datacenters are available", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulWorkspace, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const scopeButton = Array.from(host.querySelectorAll("button")).find((button) => button.title === "consul.ui.scope");
|
||||
expect(scopeButton).toBeTruthy();
|
||||
scopeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
|
||||
expect(document.body.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("dc1");
|
||||
});
|
||||
|
||||
it("routes the global refresh action to the active tools tab", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(ConsulWorkspace, { connectionId: "connection-1" });
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await settle();
|
||||
|
||||
const toolsTab = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "consul.workspace.tools");
|
||||
expect(toolsTab).toBeTruthy();
|
||||
toolsTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
||||
await settle();
|
||||
expect(toolsTab!.getAttribute("aria-selected")).toBe("true");
|
||||
await vi.waitFor(() => expect(mocks.listQueries).toHaveBeenCalled());
|
||||
mocks.listQueries.mockClear();
|
||||
|
||||
const refreshButton = Array.from(host.querySelectorAll("button")).find((button) => button.title === "consul.ui.refresh");
|
||||
expect(refreshButton).toBeTruthy();
|
||||
refreshButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await vi.waitFor(() => expect(mocks.listQueries).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,7 @@ const showModified = ref(true);
|
||||
|
||||
let syncPlanRequestId = 0;
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(connection.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
const filteredSourceTables = computed(() => {
|
||||
|
||||
@@ -58,7 +58,7 @@ const targetSchemas = ref<string[]>([]);
|
||||
const sourceDbVersion = ref<string | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "etcd", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
|
||||
@@ -295,6 +295,7 @@ const labels = computed(() => ({
|
||||
editKey: t("etcd.editKey"),
|
||||
delete: t("etcd.delete"),
|
||||
deleteTitle: t("etcd.deleteTitle"),
|
||||
keyLabel: t("etcd.key"),
|
||||
keyPlaceholder: t("etcd.keyPlaceholder"),
|
||||
keyRequired: t("etcd.keyRequired"),
|
||||
saved: t("etcd.saved"),
|
||||
@@ -303,6 +304,9 @@ const labels = computed(() => ({
|
||||
rename: t("etcd.rename"),
|
||||
clone: t("etcd.clone"),
|
||||
copyKey: t("etcd.copyKey"),
|
||||
copy: t("grid.copy"),
|
||||
copied: t("grid.copied"),
|
||||
copyFailed: t("grid.copyFailed"),
|
||||
export: t("etcd.export"),
|
||||
history: t("etcd.history"),
|
||||
restore: t("etcd.restore"),
|
||||
@@ -1077,6 +1081,9 @@ defineExpose({ focusSearch, refresh });
|
||||
:read-only="readOnly"
|
||||
:enable-multi-select="true"
|
||||
:on-watch-key="openWatchForKey"
|
||||
export-format="dbx-etcd-bundle"
|
||||
export-file-extension=".dbx-etcd.json"
|
||||
export-fallback-name="etcd-key"
|
||||
:lease-options="leaseOptions"
|
||||
:on-lease-options-requested="refreshLeaseOptions"
|
||||
@refresh-requested="refreshTtlCapability"
|
||||
|
||||
@@ -103,7 +103,7 @@ const exportElapsedText = computed(() => {
|
||||
return formatDataTransferDuration((exportFinishedAt.value ?? currentTime.value) - exportStartedAt.value);
|
||||
});
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const canExport = computed(() => {
|
||||
const hasContent = includeStructure.value || includeData.value || includeObjects.value;
|
||||
|
||||
@@ -99,6 +99,7 @@ const assetIcons: Record<string, string> = {
|
||||
rocketmq: "rocketmq",
|
||||
rabbitmq: "rabbitmq",
|
||||
nacos: "nacos.png",
|
||||
consul: "consul",
|
||||
iris: "iris",
|
||||
influxdb: "influxdb",
|
||||
victoriametrics: "victoriametrics.png",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,19 @@ const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.ur
|
||||
const etcdBrowserSource = readFileSync(new URL("../../etcd/EtcdKeyBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("KvKeyBrowser node export", () => {
|
||||
it("keeps directory toggles out of the detail-loading path", () => {
|
||||
expect(browserSource).toContain('if (node.kind === "lazy") return node.hasValue === true;');
|
||||
expect(browserSource).toContain("if (props.enableNodeActions && nodeHasValue(node)) void loadSelectedKey(routeFromNode(node));");
|
||||
expect(browserSource).not.toContain("function nodeMayHaveValue");
|
||||
});
|
||||
|
||||
it("keeps an expanded virtual Consul directory open when its exact Key probe is absent", () => {
|
||||
expect(browserSource).toContain("const lazyNode = lazyTreeState.nodeByKey.get(key);");
|
||||
expect(browserSource).toContain("if (lazyNode?.hasChildren)");
|
||||
expect(browserSource).toContain("lazyNode.hasValue = false;");
|
||||
expect(browserSource).toContain("void refreshLazyParent(parentLazyKvPath(key, props.lazyPathStyle));");
|
||||
});
|
||||
|
||||
it("shows export for both value keys and virtual directories", () => {
|
||||
expect(browserSource).toContain("if (props.api.exportScope || nodeHasValue(node))");
|
||||
expect(browserSource).toContain('kind: nodeIsExpandable(node) ? "prefix" : "key"');
|
||||
|
||||
@@ -20,4 +20,12 @@ describe("KvKeyBrowser edit dialog layout", () => {
|
||||
expect(browserSource).toContain('<DialogFooter class="mx-0 mb-0 shrink-0 border-t px-6 py-5">');
|
||||
expect(browserSource).toContain('class="h-10 min-w-20" @click="showHistoryDialog = false"');
|
||||
});
|
||||
|
||||
it("keeps an accessible copy action in the upper-right corner of the value panel", () => {
|
||||
expect(browserSource).toContain('class="absolute right-2 top-2 z-10 h-8 w-8');
|
||||
expect(browserSource).toContain('@click="copySelectedValue"');
|
||||
expect(browserSource).toContain('<Check v-if="selectedValueCopied"');
|
||||
expect(browserSource).toContain('<Copy v-else class="h-4 w-4" />');
|
||||
expect(browserSource).toMatch(/<pre\s+data-native-clipboard/);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,48 @@ describe("KvKeyBrowser search and split layout", () => {
|
||||
expect(browserSource).not.toContain("filterKvKeysBySearch(result.keys, query)");
|
||||
});
|
||||
|
||||
it("focuses a matching Consul directory instead of nesting it beneath itself", () => {
|
||||
expect(browserSource).toContain('const focusedDirectory = props.lazyPathStyle === "relative" && rootPath && !rootPath.endsWith("/")');
|
||||
expect(browserSource).toContain("key.key === `${rootPath}/`");
|
||||
expect(browserSource).toContain("const childResult = await props.api.listPrefix(context.connectionId, focusedDirectory.key, pageSize, null, { recursive: false });");
|
||||
expect(browserSource).toContain("if (rootSummary?.hasValue !== true)");
|
||||
expect(browserSource).toContain("hasValue: true");
|
||||
});
|
||||
|
||||
it("offers debounced, server-backed prefix completion for lazy Consul trees", () => {
|
||||
expect(browserSource).toContain("const remoteKeySuggestions = ref<KvKeySummary[]>([]);");
|
||||
expect(browserSource).toContain("const keySuggestionDebounceMs = 300;");
|
||||
expect(browserSource).toContain("function scheduleRemoteKeySuggestions(value: string)");
|
||||
expect(browserSource).toContain("listPrefix(connectionId, query, 8, null, { recursive: false })");
|
||||
expect(browserSource).toContain("const candidates = props.lazyHierarchy ? [...lazyTreeState.nodeByKey.values(), ...remoteKeySuggestions.value] : keys.value;");
|
||||
expect(browserSource).toContain('autocomplete="off"');
|
||||
expect(browserSource).toContain('<Popover :open="showKeySuggestions">');
|
||||
expect(browserSource).toContain('<PopoverContent v-if="showKeySuggestions" id="kv-key-prefix-suggestions"');
|
||||
expect(browserSource).toContain('if (props.lazyHierarchy && suggestion.key.endsWith("/"))');
|
||||
expect(browserSource).toContain("scheduleRemoteKeySuggestions(suggestion.key);");
|
||||
expect(browserSource).toContain('event.key !== "Enter" && event.key !== "Tab"');
|
||||
});
|
||||
|
||||
it("drops stale lazy tree responses after a connection or root generation changes", () => {
|
||||
expect(browserSource).toContain("generation: ++keyLoadGeneration");
|
||||
expect(browserSource).toContain("function lazyLoadContextValid(context: LazyLoadContext)");
|
||||
expect(browserSource).toContain("context.connectionId === props.connectionId");
|
||||
expect(browserSource).toContain("lazyTreeState.nodeByKey.get(parentKey) !== node");
|
||||
expect(browserSource).toContain("keyLoadGeneration++;");
|
||||
});
|
||||
|
||||
it("highlights the matching search query only for the selected search result", () => {
|
||||
expect(browserSource).toContain("interface KvSearchHighlight");
|
||||
expect(browserSource).toMatch(/const activeSearchHighlight = computed\(\(\) => \(?props\.searchHighlight\?\.key === selectedKey\.value/);
|
||||
expect(browserSource).toContain("function highlightText(value: string, enabled: boolean)");
|
||||
expect(browserSource).toContain("selectedValueHighlightSegments");
|
||||
});
|
||||
|
||||
it("does not select Key text while opening its context menu", () => {
|
||||
expect(browserSource).toContain("select-none items-center");
|
||||
expect(browserSource).toContain("@mousedown.right.prevent");
|
||||
});
|
||||
|
||||
it("uses a persisted draggable split between the Key tree and details", () => {
|
||||
expect(browserSource).toContain('<Splitpanes class="kv-browser-splitpanes min-h-0 flex-1" @resized="handleKvBrowserSplitResized">');
|
||||
expect(browserSource).toContain('<Pane :size="kvBrowserSplitSize" min-size="20" max-size="70">');
|
||||
|
||||
@@ -525,7 +525,8 @@ function tabMenuIcon(tab: QueryTab) {
|
||||
if (tab.externalSqlFileMissing) return AlertTriangle;
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "hbase") return Table2;
|
||||
if (tab.mode === "vector") return TableProperties;
|
||||
if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound;
|
||||
if (tab.mode === "etcd" || tab.mode === "zookeeper" || tab.mode === "consul") return KeyRound;
|
||||
if (tab.mode === "consul-overview") return Gauge;
|
||||
if (tab.mode === "etcd-dashboard") return Gauge;
|
||||
if (tab.mode === "etcd-access-control") return ShieldCheck;
|
||||
if (tab.mode === "nacos") return Network;
|
||||
@@ -671,7 +672,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'consul-overview'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
|
||||
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
|
||||
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
|
||||
@@ -867,7 +869,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'consul-overview'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
|
||||
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" class="h-3.5 w-3.5" />
|
||||
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
|
||||
|
||||
@@ -53,6 +53,8 @@ const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/Etcd
|
||||
const EtcdDashboard = defineAsyncComponent(() => import("@/components/etcd/EtcdDashboard.vue"));
|
||||
const EtcdAccessControl = defineAsyncComponent(() => import("@/components/etcd/EtcdAccessControl.vue"));
|
||||
const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zookeeper/ZooKeeperKeyBrowser.vue"));
|
||||
const ConsulOverview = defineAsyncComponent(() => import("@/components/consul/ConsulOverview.vue"));
|
||||
const ConsulWorkspace = defineAsyncComponent(() => import("@/components/consul/ConsulWorkspace.vue"));
|
||||
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
|
||||
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
|
||||
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
|
||||
@@ -242,6 +244,8 @@ function openDataGridExtractorConfiguration() {
|
||||
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const etcdDashboardRef = ref<{ refresh?: () => boolean }>();
|
||||
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const consulOverviewRef = ref<{ refresh?: () => boolean }>();
|
||||
const consulWorkspaceRef = ref<SearchableBrowserHandle>();
|
||||
const objectBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const activeTableMeta = computed(() => props.activeTab.tableMeta);
|
||||
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
|
||||
@@ -778,6 +782,7 @@ function focusSearch(): boolean {
|
||||
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "query") return queryEditorRef.value?.openSearch() ?? false;
|
||||
return dataGridRef.value?.focusSearch() ?? false;
|
||||
@@ -795,6 +800,8 @@ function refreshData(): boolean {
|
||||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "etcd-dashboard") return etcdDashboardRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "consul-overview") return consulOverviewRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.refresh?.() ?? false;
|
||||
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
|
||||
if (canReloadUnavailableDataTab(props.activeTab)) {
|
||||
emit("reload");
|
||||
@@ -1941,6 +1948,19 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'consul-overview'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<ConsulOverview ref="consulOverviewRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Consul management workspace -->
|
||||
<template v-else-if="activeTab.mode === 'consul'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<ConsulWorkspace ref="consulWorkspaceRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
|
||||
@@ -109,6 +109,7 @@ const supportsExplain = computed(() => {
|
||||
dbType !== "chromadb" &&
|
||||
dbType !== "etcd" &&
|
||||
dbType !== "zookeeper" &&
|
||||
dbType !== "consul" &&
|
||||
dbType !== "mq" &&
|
||||
dbType !== "nacos" &&
|
||||
dbType !== "victoriametrics"
|
||||
@@ -490,6 +491,7 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
activeConnection?.db_type !== 'weaviate' &&
|
||||
activeConnection?.db_type !== 'chromadb' &&
|
||||
activeConnection?.db_type !== 'zookeeper' &&
|
||||
activeConnection?.db_type !== 'consul' &&
|
||||
!isSingleDb
|
||||
"
|
||||
class="flex items-center gap-1"
|
||||
|
||||
@@ -252,7 +252,7 @@ const isRootListPartial = computed(() => sidebarFilterGuards.value.isRootListPar
|
||||
|
||||
const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
||||
connection: ["connection"],
|
||||
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "mongo-db"],
|
||||
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "consul-root", "mongo-db"],
|
||||
schema: ["schema"],
|
||||
table: ["table", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"],
|
||||
view: ["view"],
|
||||
@@ -1094,7 +1094,7 @@ function resolveLoadedLocateTarget(target: ActiveTabSidebarTarget, candidate: Qu
|
||||
}
|
||||
|
||||
async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean }) {
|
||||
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "etcd-dashboard" || target.type === "etcd-access-control" || target.type === "zookeeper-root") return;
|
||||
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "etcd-dashboard" || target.type === "etcd-access-control" || target.type === "zookeeper-root" || target.type === "consul-root") return;
|
||||
const connId = target.connectionId;
|
||||
if (!connId) return;
|
||||
|
||||
@@ -1132,7 +1132,7 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
|
||||
}
|
||||
}
|
||||
|
||||
if (config.db_type === "mq" || config.db_type === "nacos") return;
|
||||
if (config.db_type === "mq" || config.db_type === "nacos" || config.db_type === "consul") return;
|
||||
if (!("database" in target) || !target.database) return;
|
||||
|
||||
// Find the database node
|
||||
|
||||
@@ -94,7 +94,7 @@ import {
|
||||
usesTreeSchemaMode,
|
||||
isSingleDatabase,
|
||||
} from "@/lib/database/databaseCapabilities";
|
||||
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
@@ -538,8 +538,31 @@ function isGroupLabel(node: TreeNode): boolean {
|
||||
return groupTypes.has(node.type);
|
||||
}
|
||||
|
||||
async function openDirectNavigationNode(node: TreeNode) {
|
||||
if (!node.connectionId) return;
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const connectionName = connectionStore.getConfig(node.connectionId)?.name || "Consul";
|
||||
if (node.type === "consul-root") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:keys`, "consul");
|
||||
refreshActiveKvBrowserAfterOpen("consul", node.connectionId);
|
||||
} else if (node.type === "consul-overview") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:${t("consul.ui.overview")}`, "consul-overview");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
const node = activeNode.value;
|
||||
if (isDirectNavigationTreeNode(node.type)) {
|
||||
try {
|
||||
await openDirectNavigationNode(node);
|
||||
} catch (e: any) {
|
||||
const errMsg = e?.message || String(e);
|
||||
if (errMsg.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
openDriverStoreForInstallError(errMsg, node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.isLoading) {
|
||||
if (node.isExpanded) {
|
||||
node.isExpanded = false;
|
||||
@@ -607,6 +630,8 @@ async function toggle() {
|
||||
await connectionStore.loadEtcdRoot(node.connectionId);
|
||||
} else if (config?.db_type === "zookeeper") {
|
||||
await connectionStore.loadZooKeeperRoot(node.connectionId);
|
||||
} else if (config?.db_type === "consul") {
|
||||
await connectionStore.loadConsulRoot(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
@@ -779,7 +804,7 @@ function runRowClickAction(clickDetail: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshActiveKvBrowserAfterOpen(mode: "etcd" | "zookeeper", connectionId: string) {
|
||||
function refreshActiveKvBrowserAfterOpen(mode: "etcd" | "zookeeper" | "consul", connectionId: string) {
|
||||
void nextTick(() => {
|
||||
window.dispatchEvent(new CustomEvent("dbx-refresh-active-kv-browser", { detail: { mode, connectionId } }));
|
||||
});
|
||||
@@ -3385,7 +3410,7 @@ const canOpenSqlFileExecution = computed(() => {
|
||||
const canExportAllDatabases = computed(() => {
|
||||
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
|
||||
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
|
||||
return !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(dbType || "");
|
||||
return !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
|
||||
});
|
||||
|
||||
const canOpenDiagram = computed(() => {
|
||||
@@ -4328,7 +4353,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control" || node.type === "zookeeper-root") {
|
||||
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control" || node.type === "zookeeper-root" || node.type === "consul-root" || node.type === "consul-overview") {
|
||||
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import { useDragSort } from "@/composables/useDragSort";
|
||||
import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime";
|
||||
import { treeNodePinKey } from "@/lib/app/pinnedItems";
|
||||
import { isTreeGroupNodeType } from "@/lib/sidebar/treeNodeGroup";
|
||||
import { shouldActivateTreeNodeOnSingleClick } from "@/lib/sidebar/treeNodeClick";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -265,7 +266,10 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
case "etcd-access-control":
|
||||
return { icon: ShieldCheck, colorClass: "text-sky-500" };
|
||||
case "zookeeper-root":
|
||||
case "consul-root":
|
||||
return { icon: Database, colorClass: "text-blue-500" };
|
||||
case "consul-overview":
|
||||
return { icon: Gauge, colorClass: "text-blue-500" };
|
||||
case "mongo-db":
|
||||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "mongo-gridfs":
|
||||
@@ -1069,7 +1073,7 @@ function onClick(event: MouseEvent) {
|
||||
}
|
||||
selectSingleTreeNode(props.node);
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
if (settingsStore.editorSettings.sidebarActivation === "double" && props.node.type !== "load-more") return;
|
||||
if (!shouldActivateTreeNodeOnSingleClick(props.node.type, settingsStore.editorSettings.sidebarActivation) && props.node.type !== "load-more") return;
|
||||
treeRuntime.handleRowClick(props.node, event.detail);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ function resetPerFileState() {
|
||||
currentFileName.value = "";
|
||||
}
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const QUICK_OPEN_MAX_RESULTS = 200;
|
||||
const INITIAL_SQL_LIBRARY_LIMIT = 20;
|
||||
const INITIAL_SQL_FILE_LIMIT = 20;
|
||||
|
||||
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos"]);
|
||||
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
|
||||
export interface QuickOpenItem {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import en from "../locales/en";
|
||||
import es from "../locales/es";
|
||||
import it_ from "../locales/it";
|
||||
import ja from "../locales/ja";
|
||||
import ko from "../locales/ko";
|
||||
import ptBR from "../locales/pt-BR";
|
||||
import zhCN from "../locales/zh-CN";
|
||||
import zhTW from "../locales/zh-TW";
|
||||
|
||||
const locales: Array<[string, Record<string, unknown>]> = [
|
||||
["en", en],
|
||||
["es", es],
|
||||
["it", it_],
|
||||
["ja", ja],
|
||||
["ko", ko],
|
||||
["pt-BR", ptBR],
|
||||
["zh-CN", zhCN],
|
||||
["zh-TW", zhTW],
|
||||
];
|
||||
|
||||
function leafKeys(value: unknown, prefix = ""): string[] {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return [prefix];
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) => leafKeys(child, prefix ? `${prefix}.${key}` : key));
|
||||
}
|
||||
|
||||
describe("Consul advanced UI locale parity", () => {
|
||||
const expected = leafKeys((en as { consul: { ui: unknown } }).consul.ui).sort();
|
||||
const expectedTools = leafKeys((en as { consul: { tools: unknown } }).consul.tools).sort();
|
||||
|
||||
it.each(locales)("%s exposes the full consul.ui key set", (_name, locale) => {
|
||||
const ui = (locale as { consul: { ui: unknown } }).consul.ui;
|
||||
expect(leafKeys(ui).sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(locales)("%s exposes the full consul.tools key set", (_name, locale) => {
|
||||
const tools = (locale as { consul: { tools: unknown } }).consul.tools;
|
||||
expect(leafKeys(tools).sort()).toEqual(expectedTools);
|
||||
});
|
||||
|
||||
it("uses Chinese labels for the Consul KV workflow", () => {
|
||||
const consul = (zhCN as { consul: { tools: Record<string, unknown>; keyLabel: string; valueContent: string; format: string } }).consul;
|
||||
const tools = consul.tools;
|
||||
expect(tools.search).toBe("全文搜索");
|
||||
expect(tools.exportPrefix).toBe("导出");
|
||||
expect(tools.import).toBe("导入");
|
||||
expect(tools.migrate).toBe("同步");
|
||||
expect(tools.deletePrefix).toBe("前缀删除");
|
||||
expect(consul.keyLabel).toBe("键(Key)");
|
||||
expect(consul.valueContent).toBe("值");
|
||||
expect(consul.format).toBe("格式");
|
||||
});
|
||||
|
||||
it("localizes Consul service, Session, and capability labels", () => {
|
||||
const ui = (zhCN as { consul: { ui: Record<string, unknown> } }).consul.ui;
|
||||
expect(ui.serviceKind).toBe("服务类型(ServiceKind)");
|
||||
expect(ui.serviceWeights).toBe("通过 / 警告权重");
|
||||
expect(ui.behaviorRelease).toBe("释放锁(release)");
|
||||
expect(ui.behaviorDelete).toBe("删除 Key(delete)");
|
||||
expect(ui.capabilityDisabled).toBe("未启用");
|
||||
});
|
||||
|
||||
it.each(["ConsulOverview", "ConsulServices", "ConsulHealth", "ConsulSessions", "ConsulAcl", "ConsulScope", "ConsulMesh", "ConsulTools", "ConsulOperator", "ConsulWorkspace"])("%s uses the i18n composable", (component) => {
|
||||
const source = readFileSync(new URL(`../../components/consul/${component}.vue`, import.meta.url), "utf8");
|
||||
expect(source).toContain('import { useI18n } from "vue-i18n"');
|
||||
expect(source).toContain("useI18n()");
|
||||
});
|
||||
|
||||
it("keeps Consul user-facing labels out of component templates", () => {
|
||||
const components = ["ConsulServices", "ConsulHealth", "ConsulSessions", "ConsulMesh", "ConsulKeyBrowser", "ConsulOperator"];
|
||||
const forbidden = [
|
||||
"Catalog nodes",
|
||||
"No visible services",
|
||||
"No visible nodes",
|
||||
"Agent target:",
|
||||
"Watch paused",
|
||||
"Watching index",
|
||||
"Pause watch",
|
||||
"Start watch",
|
||||
"HTTP Agent registrations belong",
|
||||
"Catalog instances",
|
||||
"Node services",
|
||||
"Local Agent services",
|
||||
"Enable maintenance",
|
||||
"Disable maintenance",
|
||||
"No matching health checks",
|
||||
"Service instances",
|
||||
"Local Agent checks",
|
||||
'placeholder="Watch prefix"',
|
||||
">Key</Badge>",
|
||||
">Value</Badge>",
|
||||
'placeholder="UPDATE AUTOPILOT"',
|
||||
'placeholder="CHANGE KEYRING"',
|
||||
'placeholder="UPDATE LICENSE"',
|
||||
">ServiceKind<",
|
||||
">Passing / Warning<",
|
||||
">Node ID<",
|
||||
">TaggedAddresses<",
|
||||
">EnableTagOverride<",
|
||||
">Behavior<",
|
||||
">CreateIndex<",
|
||||
">ModifyIndex<",
|
||||
"Session {{ row.session }}",
|
||||
];
|
||||
|
||||
for (const component of components) {
|
||||
const source = readFileSync(new URL(`../../components/consul/${component}.vue`, import.meta.url), "utf8");
|
||||
const template = source.slice(source.indexOf("<template>"));
|
||||
for (const literal of forbidden) expect(template, `${component} contains ${literal}`).not.toContain(literal);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
// Shared fallback for Consul's advanced workspaces. Individual locales may override these keys.
|
||||
export const consulUiMessages = {
|
||||
ui: {
|
||||
unknown: "Unknown",
|
||||
peeringPending: "Pending",
|
||||
peeringEstablishing: "Establishing",
|
||||
peeringActive: "Active",
|
||||
peeringFailing: "Failing",
|
||||
peeringDeleting: "Deleting",
|
||||
peeringTerminated: "Terminated",
|
||||
refresh: "Refresh",
|
||||
cancel: "Cancel",
|
||||
create: "Create",
|
||||
update: "Update",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
apply: "Apply",
|
||||
close: "Close",
|
||||
hide: "Hide",
|
||||
reveal: "Reveal",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
copyFailed: "Copy failed",
|
||||
copyOnce: "Copy once",
|
||||
pause: "Pause",
|
||||
watchAction: "Watch",
|
||||
watchPrefix: "Watch prefix",
|
||||
watchModePrefix: "Prefix",
|
||||
watchModeKey: "Exact key",
|
||||
sessionProtected: "Protected by Session",
|
||||
sessionProtectedHint: "Editing and deletion are disabled to protect this distributed lock.",
|
||||
atomicTransaction: "Atomic transaction",
|
||||
nonAtomicBatches: "Non-atomic across {count} batches",
|
||||
batchOperation: "Batch {batch} · OpIndex {op}",
|
||||
operationTotals: "Succeeded {succeeded} · Conflicted {conflicted} · Skipped {skipped} · Failed {failed}",
|
||||
deleteOperationTotals: "Succeeded {succeeded} · Conflicted {conflicted} · Failed {failed}",
|
||||
clientSideMigration: "DBX reads from the source and writes to the target. This is not native Consul cross-Datacenter replication.",
|
||||
bundleFileType: "DBX Consul KV Bundle",
|
||||
sourceKeyMissingModifyIndex: "Source key has no ModifyIndex",
|
||||
transactionRejected: "Consul rejected the transaction",
|
||||
overview: "Cluster overview",
|
||||
capabilities: "Capabilities",
|
||||
probeLoading: "Loading",
|
||||
probeReady: "Ready",
|
||||
probeForbidden: "Forbidden",
|
||||
probeUnsupported: "Unsupported",
|
||||
probeError: "Error",
|
||||
autopilotHealth: "Autopilot health",
|
||||
datacenter: "Datacenter",
|
||||
agentNode: "Agent node",
|
||||
agentAddress: "Agent address",
|
||||
version: "Version",
|
||||
mode: "Mode",
|
||||
server: "Server",
|
||||
client: "Client",
|
||||
leader: "Leader",
|
||||
raftPeers: "Raft peers",
|
||||
membersAlive: "Members alive",
|
||||
members: "Members",
|
||||
node: "Node",
|
||||
address: "Address",
|
||||
status: "Status",
|
||||
alive: "alive",
|
||||
leaving: "leaving",
|
||||
failed: "failed",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
pageStatus: "{page} / {pages} · {total} total",
|
||||
searchAllServices: "Search all services",
|
||||
searchAllNodes: "Search all nodes",
|
||||
searchLocalServices: "Search local Agent services",
|
||||
query: "Query",
|
||||
searchHealthChecks: "Search check name, ID, node, service, output, or notes",
|
||||
allCheckTypes: "All check types",
|
||||
clearFilters: "Clear filters",
|
||||
healthResultCount: "{filtered} of {total} checks",
|
||||
services: "Services",
|
||||
nodes: "Nodes",
|
||||
service: "Service",
|
||||
catalogServices: "Catalog services",
|
||||
catalogNodes: "Catalog nodes",
|
||||
noVisibleServices: "No visible services",
|
||||
noVisibleNodes: "No visible nodes",
|
||||
noMatchingServices: "No matching services",
|
||||
noMatchingNodes: "No matching nodes",
|
||||
agentTarget: "Agent target: {target}",
|
||||
agentTargetVerifying: "Verifying Agent target",
|
||||
unknownWritesDisabled: "unknown - writes disabled",
|
||||
targetAgent: "the target Agent",
|
||||
agentWriteReady: "Writable",
|
||||
agentReadOnly: "Read-only",
|
||||
agentWriteDisabledHint: "No matching Agent target is configured, so maintenance and deregistration are disabled. Detected Agent: {target}.",
|
||||
register: "Register",
|
||||
registrationHint: "HTTP Agent registrations belong to the target Agent runtime and do not modify its persistent configuration files.",
|
||||
name: "Name",
|
||||
id: "ID",
|
||||
port: "Port",
|
||||
tags: "Tags (comma-separated)",
|
||||
registerOn: "Register on {node}",
|
||||
catalogInstances: "Catalog instances",
|
||||
nodeServices: "Node services",
|
||||
serviceId: "Service ID",
|
||||
localAgentServices: "Local Agent services",
|
||||
enableMaintenance: "Enable maintenance",
|
||||
disableMaintenance: "Disable maintenance",
|
||||
deregister: "Deregister",
|
||||
deregisterService: "Deregister local service {id} from {node}?",
|
||||
maintenanceReason: "DBX user request",
|
||||
serviceDetails: "{service} details",
|
||||
kind: "Kind",
|
||||
serviceKind: "ServiceKind",
|
||||
standard: "standard",
|
||||
metadata: "Metadata",
|
||||
nodeMeta: "Node meta",
|
||||
serviceMeta: "Service meta",
|
||||
nodeId: "Node ID",
|
||||
serviceWeights: "Passing / Warning",
|
||||
taggedAddresses: "TaggedAddresses",
|
||||
enableTagOverride: "EnableTagOverride",
|
||||
agent: "Agent",
|
||||
watch: "Watch",
|
||||
writeOperation: "Write operation",
|
||||
localServiceDetails: "Local service details",
|
||||
agentIdentityRequestTimedOut: "Loading Agent identity timed out.",
|
||||
localServicesRequestTimedOut: "Loading local Agent services timed out.",
|
||||
localChecksRequestTimedOut: "Loading local Agent checks timed out.",
|
||||
healthChecks: "Health checks",
|
||||
state: "State",
|
||||
nodeName: "Node name",
|
||||
allStates: "All states",
|
||||
passing: "Passing",
|
||||
warning: "Warning",
|
||||
critical: "Critical",
|
||||
watchPaused: "Watch paused",
|
||||
watchingIndex: "Watching index {index}",
|
||||
pauseWatch: "Pause watch",
|
||||
startWatch: "Start watch",
|
||||
checkRegistrationHint: "Agent checks are runtime registrations on {node} and are not persisted to Agent configuration files.",
|
||||
type: "Type",
|
||||
ttl: "TTL",
|
||||
http: "HTTP",
|
||||
tcp: "TCP",
|
||||
grpc: "gRPC",
|
||||
docker: "Docker",
|
||||
script: "Script",
|
||||
arguments: "Arguments",
|
||||
containerId: "Container ID",
|
||||
endpoint: "Endpoint",
|
||||
interval: "Interval",
|
||||
timeout: "Timeout",
|
||||
commandArguments: "Command arguments",
|
||||
clusterHealth: "Cluster health",
|
||||
nodeHealth: "Node health: {node}",
|
||||
serviceHealth: "Service health: {service}",
|
||||
noMatchingHealthChecks: "No matching health checks",
|
||||
serviceInstances: "Service instances",
|
||||
localAgentChecks: "Local Agent checks",
|
||||
maintenance: "maintenance",
|
||||
ttlPass: "TTL pass",
|
||||
ttlWarn: "TTL warn",
|
||||
ttlFail: "TTL fail",
|
||||
deregisterCheck: "Deregister check {id} from {node}?",
|
||||
checkDetails: "{check} details",
|
||||
checkId: "Check ID",
|
||||
notes: "Notes",
|
||||
output: "Output",
|
||||
definition: "Definition",
|
||||
indexes: "CreateIndex / ModifyIndex",
|
||||
sessionsAndLocks: "Sessions and locks",
|
||||
sessions: "Sessions",
|
||||
sessionDescription: "A Session is a lease-like contract used by Consul KV locks. TTL Sessions require renewal; Sessions without TTL remain until explicitly destroyed or invalidated by their checks.",
|
||||
createSession: "Create Session",
|
||||
searchSessions: "Search Session name, ID, node, or check",
|
||||
sessionResultCount: "Showing {filtered} of {total} Sessions",
|
||||
noMatchingSessions: "No matching Sessions",
|
||||
allSessionTypes: "All Session types",
|
||||
ttlSessions: "TTL Sessions",
|
||||
persistentSessions: "No-TTL Sessions",
|
||||
allBehaviors: "All behaviors",
|
||||
aclFilteredSessions: "The Session list was filtered by ACLs",
|
||||
sessionRequestTimedOut: "Loading Sessions timed out. Check the Consul connection and try again.",
|
||||
complete: "Complete",
|
||||
lock: "Lock",
|
||||
lockManagement: "KV lock management",
|
||||
lockIndex: "LockIndex {index}",
|
||||
lockDelay: "Lock delay",
|
||||
lockDelayHint: "0s–60s; delays reacquisition after invalidation.",
|
||||
nodeOptional: "Node (optional)",
|
||||
behavior: "Behavior",
|
||||
behaviorRelease: "release",
|
||||
behaviorDelete: "delete",
|
||||
behaviorHint: "release keeps held keys; delete removes them when the Session expires.",
|
||||
releaseLocks: "Release locks",
|
||||
deleteKeys: "Delete keys",
|
||||
deleteBehaviorWarning: "Behavior=delete removes every key held by this Session when it is invalidated. Continue?",
|
||||
checks: "Checks",
|
||||
nodeChecks: "Node checks",
|
||||
serviceChecks: "Service checks",
|
||||
nodeCheckShort: "Node",
|
||||
serviceCheckShort: "Service",
|
||||
serviceChecksPlaceholder: "service:api:health@namespace-a",
|
||||
serviceChecksHint: "Comma-separated check IDs; use ID@namespace for a scoped check.",
|
||||
lockHint: "Inspect the key, select a Session, then acquire or release the advisory lock.",
|
||||
key: "Key",
|
||||
inspectKey: "Inspect key",
|
||||
keyExists: "Key exists",
|
||||
keyNotFound: "Key does not exist",
|
||||
keyUnlocked: "Key is not currently held",
|
||||
keyHeldBy: "Held by Session {session}",
|
||||
sessionValue: "Session {id}",
|
||||
modifyIndexValue: "ModifyIndex {index}",
|
||||
createIndex: "CreateIndex",
|
||||
modifyIndexLabel: "ModifyIndex",
|
||||
selectSession: "Select a Session",
|
||||
sessionId: "Session ID",
|
||||
expectedModifyIndex: "Expected ModifyIndex (0 for new key)",
|
||||
value: "Value",
|
||||
releaseMatchingSession: "Release",
|
||||
acquire: "Acquire",
|
||||
unnamedSession: "Unnamed Session",
|
||||
heldKeys: "Held keys",
|
||||
heldKeysIncomplete: "Results are incomplete or ACL-filtered",
|
||||
stop: "Stop",
|
||||
renewNow: "Renew now",
|
||||
autoRenew: "Auto renew",
|
||||
autoRenewPageHint: "Auto renewal runs only while this connection and page remain active.",
|
||||
noRenewRequired: "No renewal needed",
|
||||
noTtl: "No TTL",
|
||||
ttlOptional: "TTL (optional)",
|
||||
ttlRangeHint: "Leave empty for a persistent Session, or use 10s–24h.",
|
||||
invalidSessionTtl: "TTL must be empty or a valid duration from 10s to 24h.",
|
||||
invalidLockDelay: "Lock delay must be a valid duration from 0s to 60s.",
|
||||
destroy: "Destroy",
|
||||
noHeldKeys: "No held keys",
|
||||
noVisibleSessions: "No visible Sessions",
|
||||
destroySession: "Destroy Session {id}? Locks using this Session may be released or deleted by its Behavior.",
|
||||
destroySessionImpact: "Destroy Session {id}? Behavior: {behavior}. Held keys ({count}): {keys}",
|
||||
sessionImpactIncomplete: "Session impact is incomplete or ACL-filtered. Destruction is blocked.",
|
||||
sessionDetails: "TTL {ttl} · {behavior} · checks {checks}",
|
||||
tokens: "Tokens",
|
||||
policies: "Policies",
|
||||
roles: "Roles",
|
||||
authMethods: "Auth Methods",
|
||||
bindingRules: "Binding Rules",
|
||||
templatedPolicies: "Templated Policies",
|
||||
capability: "Capability: {status}",
|
||||
capabilitySupported: "Supported",
|
||||
capabilityUnsupported: "Unsupported",
|
||||
capabilityDisabled: "Disabled",
|
||||
capabilityForbidden: "No permission",
|
||||
capabilityUnknown: "Unknown",
|
||||
loading: "Loading...",
|
||||
viewOrEdit: "View or edit",
|
||||
cloneToken: "Clone token",
|
||||
deleteAndInspectReferences: "Delete and inspect references",
|
||||
noResources: "No resources",
|
||||
editResource: "Edit {kind}",
|
||||
createResource: "Create {kind}",
|
||||
knownImpact: "Known impact: {tokens} tokens, {roles} roles, {bindingRules} binding rules",
|
||||
incomplete: "(incomplete)",
|
||||
reviewAclChanges: "Review ACL changes",
|
||||
deleteResource: "Delete {label}",
|
||||
tokenReferences: "Token references: {tokens} · Role references: {roles} · Binding rule references: {bindingRules}",
|
||||
impactBlocked: "Impact is incomplete or ACL-filtered. Deletion is blocked.",
|
||||
cloneTokenDescription: "Description for cloned token",
|
||||
scope: "Consul scope",
|
||||
adminPartition: "Admin Partition",
|
||||
namespace: "Namespace",
|
||||
default: "default",
|
||||
applyScope: "Apply scope",
|
||||
enterpriseCapability: "Enterprise capability: {status}. Lifecycle actions are unavailable.",
|
||||
namespaces: "Namespaces",
|
||||
lifecycleImpact: "Scoped resource impact",
|
||||
inspectImpactAndDelete: "Inspect impact and delete",
|
||||
impactAclFiltered: "Consul reports ACL-filtered results; deletion is blocked.",
|
||||
impactUnavailable: "Could not enumerate: {resources}",
|
||||
impactResource: {
|
||||
services: "Services",
|
||||
nodes: "Nodes",
|
||||
kvKeys: "KV keys",
|
||||
healthChecks: "Health checks",
|
||||
sessions: "Sessions",
|
||||
configEntries: "Config entries",
|
||||
intentions: "Intentions",
|
||||
peerings: "Peerings",
|
||||
namespaces: "Namespaces",
|
||||
aclTokens: "ACL tokens",
|
||||
aclPolicies: "ACL policies",
|
||||
aclRoles: "ACL roles",
|
||||
aclAuthMethods: "ACL auth methods",
|
||||
aclBindingRules: "ACL binding rules",
|
||||
},
|
||||
configEntries: "Config Entries",
|
||||
intentions: "Intentions",
|
||||
discoveryChain: "Discovery Chain",
|
||||
peering: "Peering",
|
||||
exportedServices: "Exported Services",
|
||||
configEntryKind: "Config entry kind",
|
||||
loadKind: "Load kind",
|
||||
newJson: "New JSON",
|
||||
modifyIndex: "ModifyIndex {index}",
|
||||
rawJson: "Raw JSON",
|
||||
newIntention: "New intention",
|
||||
sourceService: "Source service",
|
||||
destinationService: "Destination service",
|
||||
check: "Check",
|
||||
matchSource: "Match source",
|
||||
matchDestination: "Match destination",
|
||||
allowed: "Allowed",
|
||||
denied: "Denied",
|
||||
matchedIntentions: "Matched {count} intention(s)",
|
||||
details: "Details",
|
||||
serviceName: "Service name",
|
||||
compile: "Compile",
|
||||
defaultProtocol: "default protocol",
|
||||
configuredDatacenter: "configured datacenter",
|
||||
start: "Start",
|
||||
next: "Next: {nodes}",
|
||||
terminalTarget: "Terminal / target",
|
||||
upstreamTargets: "Upstream targets ({count})",
|
||||
peerName: "Peer name",
|
||||
peeringToken: "Peering token to establish",
|
||||
generateToken: "Generate token",
|
||||
establish: "Establish",
|
||||
editExportedServices: "Edit exported-services entry",
|
||||
serviceMeshConfigEntry: "Service Mesh config entry",
|
||||
applyWithCas: "Apply with CAS",
|
||||
reviewMeshChanges: "Review Service Mesh changes",
|
||||
deleteConfig: "Delete {kind}/{name}?",
|
||||
deleteIntention: "Delete intention {id}?",
|
||||
deleteIntentionExact: "Delete intention {source} -> {destination}?",
|
||||
deletePeering: "Delete peering {name}?",
|
||||
permissions: "{count} permissions",
|
||||
actionRule: "action rule",
|
||||
tools: "Consul tools",
|
||||
preparedQueries: "Prepared queries",
|
||||
events: "Events",
|
||||
coordinates: "Network coordinates",
|
||||
queryName: "Query name",
|
||||
targetService: "Target service",
|
||||
targetServiceValue: "Target service: {service}",
|
||||
cancelEdit: "Cancel edit",
|
||||
execute: "Execute",
|
||||
explain: "Explain",
|
||||
eventName: "Event name",
|
||||
payload: "Payload",
|
||||
fire: "Fire",
|
||||
deletePreparedQuery: "Delete prepared query {id}?",
|
||||
nodesDatacenter: "{count} nodes · {datacenter}",
|
||||
coordinateError: "{segment} · error {error}",
|
||||
preparedQueriesDescription: "Reusable service-discovery queries stored in Consul. Execute resolves matching healthy service instances; Explain shows the effective query. The simple editor changes only the name and target service, preserving advanced fields already stored by Consul.",
|
||||
eventsDescription: "Lightweight, best-effort messages distributed through Consul gossip. Events are transient diagnostics or notifications, not a durable message queue.",
|
||||
coordinatesDescription: "Read-only network coordinates estimated by Consul to compare network proximity. They are not geographic locations.",
|
||||
noPreparedQueries: "No prepared queries",
|
||||
noEvents: "No recent events",
|
||||
noCoordinates: "No network coordinates",
|
||||
queryExecutionResult: "Execution result · {count} instances · {datacenter}",
|
||||
noQueryNodes: "No service instances matched this query",
|
||||
queryExplanation: "Effective query",
|
||||
eventNodeFilter: "Node regex (optional)",
|
||||
eventServiceFilter: "Service regex (optional)",
|
||||
eventTagFilter: "Tag regex (optional)",
|
||||
eventPayloadBytes: "Payload {count} / 100 bytes",
|
||||
segment: "Segment",
|
||||
error: "Error",
|
||||
adjustment: "Adjustment",
|
||||
height: "Height",
|
||||
operatorReadOnly: "Operator (read-only)",
|
||||
snapshot: "Snapshot",
|
||||
snapshotRestore: "Snapshot restore",
|
||||
targetDatacenter: "Target Datacenter",
|
||||
restoreSnapshot: "Restore snapshot",
|
||||
restoreSnapshotConfirmation: "RESTORE SNAPSHOT {datacenter}",
|
||||
autopilotUpdate: "Autopilot update",
|
||||
cleanupDeadServers: "Cleanup dead servers",
|
||||
updateAutopilot: "Update Autopilot",
|
||||
updateAutopilotConfirmation: "UPDATE AUTOPILOT",
|
||||
raftOperations: "Raft operations",
|
||||
serverId: "Server ID",
|
||||
peerAddress: "Peer address (remove only)",
|
||||
reenterIdAddress: "Re-enter ID or address",
|
||||
transferLeader: "Transfer leader",
|
||||
removePeer: "Remove peer",
|
||||
gossipKeyring: "Gossip keyring",
|
||||
operation: "Operation",
|
||||
install: "Install",
|
||||
use: "Use",
|
||||
remove: "Remove",
|
||||
gossipKey: "Gossip key",
|
||||
changeKeyring: "Change keyring",
|
||||
changeKeyringConfirmation: "CHANGE KEYRING",
|
||||
licenseUpdate: "License update",
|
||||
license: "License",
|
||||
updateLicense: "Update license",
|
||||
updateLicenseConfirmation: "UPDATE LICENSE",
|
||||
},
|
||||
tools: {
|
||||
search: "Search",
|
||||
searchTitle: "Search Consul KV",
|
||||
searchQuery: "Text to find",
|
||||
prefixOptional: "Prefix (optional)",
|
||||
searchAll: "Keys and values",
|
||||
searchKeys: "Keys only",
|
||||
searchValues: "Values only",
|
||||
caseSensitive: "Case-sensitive",
|
||||
scanned: "Scanned {count} keys",
|
||||
searchLimited: "Results are limited to protect the connection.",
|
||||
noSearchResults: "No matching keys",
|
||||
searchSummary: "{matched} matches from {scanned} scanned keys",
|
||||
exportSearchResults: "Export results",
|
||||
searchResultsFileType: "Consul KV search results",
|
||||
searchResultsExported: "Exported {count} search results",
|
||||
exportPrefix: "Export prefix",
|
||||
prefix: "Prefix",
|
||||
emptyPrefixAll: "Leave empty for all accessible keys",
|
||||
exportLimitHint: "Exports include the current scope and preserve the key metadata needed for safe import.",
|
||||
exported: "Exported {count} keys",
|
||||
import: "Import",
|
||||
importTitle: "Import Consul KV bundle",
|
||||
invalidBundle: "This file is not a valid DBX Consul KV bundle",
|
||||
importApplied: "Imported {count} keys",
|
||||
migrate: "Migrate",
|
||||
migrateTitle: "Migrate Consul KV",
|
||||
sourcePrefix: "Source prefix",
|
||||
targetConnection: "Target connection",
|
||||
selectTarget: "Select a target connection",
|
||||
targetReadOnly: "The target connection is read-only.",
|
||||
conflictPolicy: "Conflict policy",
|
||||
policyAbort: "Abort on conflict",
|
||||
policySkip: "Skip conflicts",
|
||||
policyCas: "Use CAS",
|
||||
bundleSummary: "{count} keys · Datacenter {dc} · Namespace {ns} · Partition {partition}",
|
||||
creates: "Create {count}",
|
||||
updates: "Update {count}",
|
||||
unchanged: "Unchanged {count}",
|
||||
skipped: "Skipped {count}",
|
||||
conflicts: "Conflicts {count}",
|
||||
loadPreview: "Load preview",
|
||||
loadingPreview: "Loading preview...",
|
||||
apply: "Apply",
|
||||
preview: "Preview",
|
||||
deletePrefix: "Delete prefix",
|
||||
deletePrefixTitle: "Delete Consul KV prefix",
|
||||
deletePrefixPlaceholder: "Prefix to delete",
|
||||
deletePrefixWarning: "This operation deletes every visible key under the prefix. Review the preview before confirming.",
|
||||
candidates: "Candidates {count}",
|
||||
lockedCount: "Locked {count}",
|
||||
filtered: "filtered",
|
||||
complete: "complete",
|
||||
confirmDeletePrefix: "Delete confirmed keys",
|
||||
prefixDeleted: "Deleted {count} keys",
|
||||
operation: { create: "Create", update: "Update", unchanged: "Unchanged", skipped: "Skipped", conflict: "Conflict", locked: "Locked", succeeded: "Succeeded", conflicted: "Conflicted", failed: "Failed" },
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,517 @@
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
export const consul = {
|
||||
...consulUiMessages,
|
||||
ui: {
|
||||
...consulUiMessages.ui,
|
||||
atomicTransaction: "单事务原子执行",
|
||||
nonAtomicBatches: "跨 {count} 个批次不具备整体原子性",
|
||||
batchOperation: "批次 {batch} · OpIndex {op}",
|
||||
operationTotals: "成功 {succeeded} · 冲突 {conflicted} · 跳过 {skipped} · 失败 {failed}",
|
||||
deleteOperationTotals: "成功 {succeeded} · 冲突 {conflicted} · 失败 {failed}",
|
||||
clientSideMigration: "DBX 会从源连接读取后写入目标连接;这不是 Consul 原生跨 Datacenter 复制。",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败",
|
||||
sessionProtected: "受 Session 保护",
|
||||
sessionProtectedHint: "为避免破坏分布式锁,编辑与删除已禁用。",
|
||||
previousPage: "上一页",
|
||||
nextPage: "下一页",
|
||||
pageStatus: "第 {page} / {pages} 页 · 共 {total} 条",
|
||||
searchAllServices: "全局搜索服务",
|
||||
searchAllNodes: "全局搜索节点",
|
||||
searchLocalServices: "搜索本地 Agent 服务",
|
||||
query: "查询",
|
||||
searchHealthChecks: "搜索检查名称、ID、节点、服务、输出或备注",
|
||||
allCheckTypes: "全部检查类型",
|
||||
clearFilters: "清除过滤",
|
||||
healthResultCount: "显示 {filtered} / {total} 个检查",
|
||||
unknown: "未知",
|
||||
peeringPending: "等待中",
|
||||
peeringEstablishing: "正在建立",
|
||||
peeringActive: "已连接",
|
||||
peeringFailing: "连接异常",
|
||||
peeringDeleting: "正在删除",
|
||||
peeringTerminated: "已终止",
|
||||
refresh: "刷新",
|
||||
cancel: "取消",
|
||||
create: "创建",
|
||||
update: "更新",
|
||||
save: "保存",
|
||||
delete: "删除",
|
||||
edit: "编辑",
|
||||
apply: "应用",
|
||||
close: "关闭",
|
||||
pause: "暂停",
|
||||
watchAction: "监视",
|
||||
watchPrefix: "监视前缀",
|
||||
watchModePrefix: "前缀",
|
||||
watchModeKey: "精确 Key",
|
||||
bundleFileType: "DBX Consul KV 数据包",
|
||||
sourceKeyMissingModifyIndex: "源 Key 没有 ModifyIndex",
|
||||
transactionRejected: "Consul 拒绝了该事务",
|
||||
overview: "集群概览",
|
||||
capabilities: "能力探测",
|
||||
probeLoading: "加载中",
|
||||
probeReady: "可用",
|
||||
probeForbidden: "无权限",
|
||||
probeUnsupported: "不支持",
|
||||
probeError: "错误",
|
||||
autopilotHealth: "Autopilot 健康状态",
|
||||
services: "服务",
|
||||
nodes: "节点",
|
||||
service: "服务",
|
||||
node: "节点",
|
||||
catalogServices: "Catalog 服务",
|
||||
catalogNodes: "Catalog 节点",
|
||||
noVisibleServices: "没有可见服务",
|
||||
noVisibleNodes: "没有可见节点",
|
||||
noMatchingServices: "没有匹配的服务",
|
||||
noMatchingNodes: "没有匹配的节点",
|
||||
agentTarget: "Agent 目标:{target}",
|
||||
agentTargetVerifying: "正在校验 Agent 目标",
|
||||
unknownWritesDisabled: "未知,写操作已禁用",
|
||||
targetAgent: "目标 Agent",
|
||||
agentWriteReady: "可操作",
|
||||
agentReadOnly: "只读",
|
||||
agentWriteDisabledHint: "未配置匹配的 Agent 目标,因此维护和注销操作已禁用。当前检测到:{target}。",
|
||||
register: "注册",
|
||||
registrationHint: "HTTP Agent 注册仅作用于目标 Agent 的运行时,不会修改其持久化配置文件。",
|
||||
name: "名称",
|
||||
port: "端口",
|
||||
tags: "标签(逗号分隔)",
|
||||
registerOn: "注册到 {node}",
|
||||
catalogInstances: "Catalog 实例",
|
||||
nodeServices: "节点服务",
|
||||
serviceId: "服务 ID",
|
||||
localAgentServices: "本地 Agent 服务",
|
||||
enableMaintenance: "启用维护模式",
|
||||
disableMaintenance: "停用维护模式",
|
||||
deregister: "注销",
|
||||
deregisterService: "从 {node} 注销本地服务 {id}?",
|
||||
maintenanceReason: "DBX 用户请求",
|
||||
serviceDetails: "{service} 详情",
|
||||
kind: "类型",
|
||||
serviceKind: "服务类型(ServiceKind)",
|
||||
standard: "标准",
|
||||
metadata: "元数据",
|
||||
nodeMeta: "节点元数据",
|
||||
serviceMeta: "服务元数据",
|
||||
nodeId: "节点 ID",
|
||||
serviceWeights: "通过 / 警告权重",
|
||||
taggedAddresses: "标记地址(TaggedAddresses)",
|
||||
enableTagOverride: "允许覆盖标签(EnableTagOverride)",
|
||||
agent: "Agent",
|
||||
writeOperation: "写操作",
|
||||
localServiceDetails: "本地服务详情",
|
||||
agentIdentityRequestTimedOut: "加载 Agent 身份信息超时。",
|
||||
localServicesRequestTimedOut: "加载本地 Agent 服务超时。",
|
||||
localChecksRequestTimedOut: "加载本地 Agent 检查超时。",
|
||||
healthChecks: "健康检查",
|
||||
state: "状态",
|
||||
nodeName: "节点名称",
|
||||
allStates: "全部状态",
|
||||
passing: "通过",
|
||||
warning: "警告",
|
||||
critical: "严重",
|
||||
watchingIndex: "正在监视索引 {index}",
|
||||
checkRegistrationHint: "Agent 检查仅注册在 {node} 的运行时中,不会持久化到 Agent 配置文件。",
|
||||
type: "类型",
|
||||
arguments: "参数",
|
||||
containerId: "Container ID",
|
||||
endpoint: "端点",
|
||||
interval: "间隔",
|
||||
timeout: "超时",
|
||||
commandArguments: "命令参数",
|
||||
clusterHealth: "集群健康状态",
|
||||
nodeHealth: "节点健康状态:{node}",
|
||||
serviceHealth: "服务健康状态:{service}",
|
||||
noMatchingHealthChecks: "没有匹配的健康检查",
|
||||
serviceInstances: "服务实例",
|
||||
localAgentChecks: "本地 Agent 检查",
|
||||
maintenance: "维护中",
|
||||
ttlPass: "TTL 通过",
|
||||
ttlWarn: "TTL 警告",
|
||||
ttlFail: "TTL 失败",
|
||||
deregisterCheck: "从 {node} 注销检查 {id}?",
|
||||
checkDetails: "{check} 详情",
|
||||
checkId: "检查 ID",
|
||||
notes: "备注",
|
||||
output: "输出",
|
||||
definition: "定义",
|
||||
indexes: "CreateIndex / ModifyIndex",
|
||||
sessionsAndLocks: "会话和锁",
|
||||
scope: "Consul 作用域",
|
||||
tools: "Consul 工具",
|
||||
loading: "加载中...",
|
||||
noResources: "没有资源",
|
||||
capability: "能力:{status}",
|
||||
capabilitySupported: "支持",
|
||||
capabilityUnsupported: "不支持",
|
||||
capabilityDisabled: "未启用",
|
||||
capabilityForbidden: "无权限",
|
||||
capabilityUnknown: "未知",
|
||||
applyScope: "应用作用域",
|
||||
impactAclFiltered: "Consul 返回了 ACL 过滤结果,删除已阻止。",
|
||||
impactUnavailable: "无法完整枚举:{resources}",
|
||||
impactResource: {
|
||||
services: "服务",
|
||||
nodes: "节点",
|
||||
kvKeys: "KV Key",
|
||||
healthChecks: "健康检查",
|
||||
sessions: "Session",
|
||||
configEntries: "配置条目",
|
||||
intentions: "Intention",
|
||||
peerings: "Peering",
|
||||
namespaces: "Namespace",
|
||||
aclTokens: "ACL Token",
|
||||
aclPolicies: "ACL Policy",
|
||||
aclRoles: "ACL Role",
|
||||
aclAuthMethods: "ACL Auth Method",
|
||||
aclBindingRules: "ACL Binding Rule",
|
||||
},
|
||||
reveal: "显示",
|
||||
copyOnce: "复制一次",
|
||||
datacenter: "数据中心",
|
||||
agentNode: "Agent 节点",
|
||||
agentAddress: "Agent 地址",
|
||||
version: "版本",
|
||||
mode: "模式",
|
||||
server: "服务端",
|
||||
client: "客户端",
|
||||
leader: "Leader",
|
||||
raftPeers: "Raft 对等节点",
|
||||
membersAlive: "存活成员",
|
||||
members: "成员",
|
||||
address: "地址",
|
||||
status: "状态",
|
||||
alive: "存活",
|
||||
leaving: "离开中",
|
||||
failed: "故障",
|
||||
tokens: "令牌",
|
||||
policies: "策略",
|
||||
roles: "角色",
|
||||
authMethods: "认证方式",
|
||||
bindingRules: "绑定规则",
|
||||
templatedPolicies: "模板策略",
|
||||
viewOrEdit: "查看或编辑",
|
||||
cloneToken: "克隆令牌",
|
||||
deleteAndInspectReferences: "检查引用后删除",
|
||||
editResource: "编辑 {kind}",
|
||||
createResource: "新建 {kind}",
|
||||
knownImpact: "已知影响:{tokens} 个令牌、{roles} 个角色、{bindingRules} 条绑定规则",
|
||||
incomplete: "(不完整)",
|
||||
reviewAclChanges: "确认 ACL 变更",
|
||||
deleteResource: "删除 {label}",
|
||||
tokenReferences: "令牌引用:{tokens} · 角色引用:{roles} · 绑定规则引用:{bindingRules}",
|
||||
impactBlocked: "影响范围不完整或已被 ACL 过滤,禁止删除。",
|
||||
cloneTokenDescription: "克隆令牌的说明",
|
||||
sessions: "会话",
|
||||
sessionDescription: "会话是 Consul 分布式锁使用的租约型契约。带 TTL 的会话需要续租;无 TTL 会话会持续存在,直到主动销毁或关联检查失效。",
|
||||
createSession: "创建会话",
|
||||
searchSessions: "搜索会话名称、ID、节点或检查项",
|
||||
sessionResultCount: "显示 {filtered} / {total} 个会话",
|
||||
noMatchingSessions: "没有匹配的会话",
|
||||
allSessionTypes: "全部会话类型",
|
||||
ttlSessions: "TTL 会话",
|
||||
persistentSessions: "无 TTL 会话",
|
||||
allBehaviors: "全部失效行为",
|
||||
aclFilteredSessions: "会话列表已被 ACL 过滤",
|
||||
sessionRequestTimedOut: "加载会话超时,请检查 Consul 连接后重试。",
|
||||
complete: "完整",
|
||||
lock: "锁",
|
||||
lockManagement: "KV 锁管理",
|
||||
lockIndex: "锁索引 {index}",
|
||||
lockDelay: "锁延迟",
|
||||
lockDelayHint: "范围 0s–60s;会话失效后延迟其他会话重新获取锁。",
|
||||
nodeOptional: "节点(可选)",
|
||||
behavior: "失效行为",
|
||||
behaviorRelease: "释放锁(release)",
|
||||
behaviorDelete: "删除 Key(delete)",
|
||||
behaviorHint: "release 会保留 Key 并释放锁;delete 会在会话失效时删除所持 Key。",
|
||||
releaseLocks: "释放锁(release)",
|
||||
deleteKeys: "删除 Key(delete)",
|
||||
deleteBehaviorWarning: "delete 行为会在会话失效时删除它持有的所有 Key。确定继续吗?",
|
||||
checks: "关联检查",
|
||||
nodeChecks: "节点检查",
|
||||
serviceChecks: "服务检查",
|
||||
nodeCheckShort: "节点",
|
||||
serviceCheckShort: "服务",
|
||||
serviceChecksPlaceholder: "service:api:health@namespace-a",
|
||||
serviceChecksHint: "多个检查 ID 用逗号分隔;作用域检查可写为 ID@命名空间。",
|
||||
lockHint: "先检查 Key,再选择会话执行获取或释放。",
|
||||
key: "Key",
|
||||
inspectKey: "检查 Key",
|
||||
keyExists: "Key 已存在",
|
||||
keyNotFound: "Key 不存在",
|
||||
keyUnlocked: "Key 当前未被会话持有",
|
||||
keyHeldBy: "由会话 {session} 持有",
|
||||
sessionValue: "会话 {id}",
|
||||
modifyIndexValue: "ModifyIndex {index}",
|
||||
createIndex: "CreateIndex",
|
||||
modifyIndexLabel: "ModifyIndex",
|
||||
selectSession: "选择会话",
|
||||
sessionId: "会话 ID",
|
||||
expectedModifyIndex: "期望的 ModifyIndex(新 Key 为 0)",
|
||||
value: "值",
|
||||
releaseMatchingSession: "释放",
|
||||
acquire: "获取",
|
||||
unnamedSession: "未命名会话",
|
||||
heldKeys: "持有的 Key",
|
||||
heldKeysIncomplete: "结果不完整或已被 ACL 过滤",
|
||||
stop: "停止",
|
||||
renewNow: "立即续租",
|
||||
autoRenew: "自动续租",
|
||||
autoRenewPageHint: "仅在当前连接和页面保持活跃时自动续租。",
|
||||
noRenewRequired: "无需续租",
|
||||
noTtl: "无 TTL",
|
||||
ttlOptional: "TTL(可选)",
|
||||
ttlRangeHint: "留空表示持久会话;填写时范围为 10s–24h。",
|
||||
invalidSessionTtl: "TTL 必须留空,或填写 10s–24h 的有效时长。",
|
||||
invalidLockDelay: "锁延迟必须填写 0s–60s 的有效时长。",
|
||||
destroy: "销毁",
|
||||
noHeldKeys: "没有持有的 Key",
|
||||
noVisibleSessions: "没有可见会话",
|
||||
destroySession: "销毁会话 {id}?该会话所持锁会按其行为释放或删除。",
|
||||
destroySessionImpact: "销毁会话 {id}?行为:{behavior}。持有的 Key({count}):{keys}",
|
||||
sessionImpactIncomplete: "会话影响范围不完整或已被 ACL 过滤,已阻止销毁。",
|
||||
sessionDetails: "TTL {ttl} · {behavior} · 检查项 {checks}",
|
||||
adminPartition: "管理分区",
|
||||
namespace: "命名空间",
|
||||
default: "默认",
|
||||
enterpriseCapability: "Enterprise 能力:{status}。生命周期操作不可用。",
|
||||
namespaces: "命名空间",
|
||||
lifecycleImpact: "作用域资源影响",
|
||||
inspectImpactAndDelete: "检查影响并删除",
|
||||
configEntries: "配置条目",
|
||||
intentions: "服务意图",
|
||||
discoveryChain: "服务发现链",
|
||||
peering: "对等连接",
|
||||
exportedServices: "导出服务",
|
||||
configEntryKind: "配置条目类型",
|
||||
loadKind: "加载类型",
|
||||
newJson: "新建 JSON",
|
||||
modifyIndex: "ModifyIndex {index}",
|
||||
rawJson: "原始 JSON",
|
||||
newIntention: "新建服务意图",
|
||||
sourceService: "源服务",
|
||||
destinationService: "目标服务",
|
||||
check: "检查",
|
||||
matchSource: "匹配源服务",
|
||||
matchDestination: "匹配目标服务",
|
||||
allowed: "允许",
|
||||
denied: "拒绝",
|
||||
matchedIntentions: "匹配到 {count} 条服务意图",
|
||||
details: "详情",
|
||||
serviceName: "服务名称",
|
||||
compile: "编译",
|
||||
defaultProtocol: "默认协议",
|
||||
configuredDatacenter: "配置的数据中心",
|
||||
start: "起点",
|
||||
next: "下一跳:{nodes}",
|
||||
terminalTarget: "终点 / 目标",
|
||||
upstreamTargets: "上游目标({count})",
|
||||
peerName: "对等连接名称",
|
||||
peeringToken: "用于建立连接的对等令牌",
|
||||
generateToken: "生成令牌",
|
||||
establish: "建立",
|
||||
editExportedServices: "编辑 exported-services 条目",
|
||||
serviceMeshConfigEntry: "服务网格配置条目",
|
||||
applyWithCas: "使用 CAS 应用",
|
||||
reviewMeshChanges: "确认服务网格变更",
|
||||
deleteConfig: "删除 {kind}/{name}?",
|
||||
deleteIntention: "删除服务意图 {id}?",
|
||||
deleteIntentionExact: "删除服务意图 {source} -> {destination}?",
|
||||
deletePeering: "删除对等连接 {name}?",
|
||||
permissions: "{count} 条权限",
|
||||
actionRule: "动作规则",
|
||||
preparedQueries: "预设查询",
|
||||
events: "事件",
|
||||
coordinates: "网络坐标",
|
||||
queryName: "查询名称",
|
||||
targetService: "目标服务",
|
||||
targetServiceValue: "目标服务:{service}",
|
||||
cancelEdit: "取消编辑",
|
||||
execute: "执行",
|
||||
explain: "说明",
|
||||
eventName: "事件名称",
|
||||
payload: "负载内容",
|
||||
fire: "触发",
|
||||
deletePreparedQuery: "删除预设查询 {id}?",
|
||||
nodesDatacenter: "{count} 个节点 · {datacenter}",
|
||||
coordinateError: "{segment} · 错误 {error}",
|
||||
preparedQueriesDescription: "保存在 Consul 中、可重复使用的服务发现查询。执行会解析符合条件的健康服务实例,说明会展示最终生效的查询。简易编辑器只修改名称和目标服务,并保留 Consul 中已有的高级字段。",
|
||||
eventsDescription: "通过 Consul Gossip 分发的轻量、尽力而为消息,适合临时诊断和通知;它不是持久化消息队列。",
|
||||
coordinatesDescription: "Consul 估算的只读网络坐标,用于比较节点间的网络距离;它不是地理位置坐标。",
|
||||
noPreparedQueries: "暂无预设查询",
|
||||
noEvents: "暂无近期事件",
|
||||
noCoordinates: "暂无网络坐标",
|
||||
queryExecutionResult: "执行结果 · {count} 个实例 · {datacenter}",
|
||||
noQueryNodes: "没有服务实例符合该查询",
|
||||
queryExplanation: "生效的查询",
|
||||
eventNodeFilter: "节点正则(可选)",
|
||||
eventServiceFilter: "服务正则(可选)",
|
||||
eventTagFilter: "标签正则(可选)",
|
||||
eventPayloadBytes: "负载 {count} / 100 字节",
|
||||
segment: "网络分段",
|
||||
error: "误差",
|
||||
adjustment: "校正值",
|
||||
height: "高度",
|
||||
operatorReadOnly: "运维(只读)",
|
||||
snapshot: "快照",
|
||||
snapshotRestore: "恢复快照",
|
||||
targetDatacenter: "目标数据中心",
|
||||
restoreSnapshot: "恢复快照",
|
||||
autopilotUpdate: "Autopilot 更新",
|
||||
cleanupDeadServers: "清理失效服务端",
|
||||
updateAutopilot: "更新 Autopilot",
|
||||
raftOperations: "Raft 操作",
|
||||
serverId: "服务端 ID",
|
||||
peerAddress: "对等节点地址(仅移除时需要)",
|
||||
reenterIdAddress: "重新输入 ID 或地址",
|
||||
transferLeader: "转移 Leader",
|
||||
removePeer: "移除对等节点",
|
||||
gossipKeyring: "Gossip 密钥环",
|
||||
operation: "操作",
|
||||
install: "安装",
|
||||
use: "使用",
|
||||
remove: "移除",
|
||||
gossipKey: "Gossip 密钥",
|
||||
changeKeyring: "修改密钥环",
|
||||
licenseUpdate: "许可证更新",
|
||||
license: "许可证",
|
||||
updateLicense: "更新许可证",
|
||||
watch: "监视",
|
||||
watchPaused: "监视已暂停",
|
||||
pauseWatch: "暂停监视",
|
||||
startWatch: "启动监视",
|
||||
},
|
||||
tools: {
|
||||
search: "全文搜索",
|
||||
searchTitle: "搜索 Consul KV",
|
||||
searchQuery: "输入要查找的内容",
|
||||
prefixOptional: "前缀(可选)",
|
||||
searchAll: "Key 和值",
|
||||
searchKeys: "仅 Key",
|
||||
searchValues: "仅值",
|
||||
caseSensitive: "区分大小写",
|
||||
scanned: "已扫描 {count} 个 Key",
|
||||
searchLimited: "为保护连接,本次结果已受限。",
|
||||
noSearchResults: "没有匹配的 Key",
|
||||
searchSummary: "已扫描 {scanned} 个 Key,匹配 {matched} 个",
|
||||
exportSearchResults: "导出结果",
|
||||
searchResultsFileType: "Consul KV 搜索结果",
|
||||
searchResultsExported: "已导出 {count} 条搜索结果",
|
||||
exportPrefix: "导出",
|
||||
prefix: "前缀",
|
||||
emptyPrefixAll: "留空则导出全部可访问 Key",
|
||||
exportLimitHint: "导出会保留当前作用域及安全导入所需的 Key 元数据。",
|
||||
exported: "已导出 {count} 个 Key",
|
||||
import: "导入",
|
||||
importTitle: "导入 Consul KV 数据包",
|
||||
invalidBundle: "该文件不是有效的 DBX Consul KV 数据包",
|
||||
importApplied: "已导入 {count} 个 Key",
|
||||
migrate: "同步",
|
||||
migrateTitle: "迁移 Consul KV",
|
||||
sourcePrefix: "源前缀",
|
||||
targetConnection: "目标连接",
|
||||
selectTarget: "选择目标连接",
|
||||
targetReadOnly: "目标连接为只读,无法写入。",
|
||||
conflictPolicy: "冲突策略",
|
||||
policyAbort: "遇到冲突即终止",
|
||||
policySkip: "跳过冲突项",
|
||||
policyCas: "使用 CAS",
|
||||
bundleSummary: "{count} 个 Key · 数据中心 {dc} · 命名空间 {ns} · 管理分区 {partition}",
|
||||
creates: "新建 {count}",
|
||||
updates: "更新 {count}",
|
||||
unchanged: "未变更 {count}",
|
||||
skipped: "跳过 {count}",
|
||||
conflicts: "冲突 {count}",
|
||||
loadPreview: "加载预览",
|
||||
loadingPreview: "正在加载预览...",
|
||||
apply: "应用",
|
||||
preview: "预览",
|
||||
deletePrefix: "前缀删除",
|
||||
deletePrefixTitle: "删除 Consul KV 前缀",
|
||||
deletePrefixPlaceholder: "输入要删除的前缀",
|
||||
deletePrefixWarning: "此操作会删除该前缀下所有可见 Key。请先核对预览,再执行确认。",
|
||||
candidates: "候选项 {count}",
|
||||
lockedCount: "已锁定 {count}",
|
||||
filtered: "已过滤",
|
||||
complete: "完整",
|
||||
confirmDeletePrefix: "删除已确认的 Key",
|
||||
prefixDeleted: "已删除 {count} 个 Key",
|
||||
operation: { create: "新建", update: "更新", unchanged: "未变更", skipped: "已跳过", conflict: "冲突", locked: "已锁定", succeeded: "成功", conflicted: "冲突", failed: "失败" },
|
||||
},
|
||||
prefixPlaceholder: "Key 前缀,例如 app/ 或 services/",
|
||||
newKey: "新建 Key",
|
||||
loadingKeys: "正在加载 Key...",
|
||||
empty: "未找到 Key",
|
||||
loadMore: "加载更多",
|
||||
selectKey: "选择一个 Key 查看其值",
|
||||
loadingValue: "正在加载值...",
|
||||
notFound: "未找到 Key",
|
||||
edit: "编辑",
|
||||
editKey: "编辑 Key",
|
||||
delete: "删除",
|
||||
deleteTitle: "删除 Consul KV Key",
|
||||
keyLabel: "键(Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key 不能为空",
|
||||
rootReadonly: "根前缀不能修改,请输入具体 Key 路径。",
|
||||
saved: "Key 已保存",
|
||||
deleted: "Key 已删除",
|
||||
base64Readonly: "请在 Base64 模式下编辑二进制值。",
|
||||
rename: "重命名",
|
||||
clone: "克隆",
|
||||
copyKey: "复制 Key",
|
||||
export: "导出",
|
||||
value: "值",
|
||||
valueContent: "值",
|
||||
format: "格式",
|
||||
metadata: "KV 元数据",
|
||||
prettyJson: "格式化",
|
||||
invalidJson: "JSON 格式无效",
|
||||
summarySize: "大小",
|
||||
conflict: "该 Key 自加载后已发生变化,请刷新后重试。",
|
||||
keyAlreadyExists: "同名 Key 已存在,请使用其他名称或编辑现有 Key。",
|
||||
aclFiltered: "部分 Consul KV Key 已被 ACL 策略过滤。",
|
||||
locked: "该 Key 正由 Consul Session 持有,不能编辑或删除。",
|
||||
valueTooLarge: "Consul KV 值不能超过 512 KiB。",
|
||||
watchKey: "监视",
|
||||
stopWatching: "停止",
|
||||
workspace: {
|
||||
overview: "概览",
|
||||
kv: "键值存储",
|
||||
services: "服务",
|
||||
health: "健康检查",
|
||||
sessions: "会话",
|
||||
scope: "作用域",
|
||||
mesh: "服务网格",
|
||||
tools: "工具",
|
||||
operator: "运维",
|
||||
refresh: "刷新能力信息",
|
||||
loading: "正在加载 Consul 能力...",
|
||||
agentIdentity: "Agent 身份",
|
||||
capabilities: "能力状态",
|
||||
version: "版本",
|
||||
edition: "版本类型",
|
||||
datacenter: "数据中心",
|
||||
node: "Agent 节点",
|
||||
mode: "Agent 模式",
|
||||
agent: "Agent API",
|
||||
acl: "访问控制",
|
||||
namespaces: "命名空间",
|
||||
partitions: "管理分区",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "支持",
|
||||
unsupported: "不支持",
|
||||
forbidden: "无权限",
|
||||
unknown: "未知",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,80 @@
|
||||
import docs from "./docs/en";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "Key",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "Rename",
|
||||
clone: "Clone",
|
||||
copyKey: "Copy Key",
|
||||
export: "Export",
|
||||
value: "Value",
|
||||
valueContent: "Value",
|
||||
format: "Format",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "Watch",
|
||||
stopWatching: "Stop",
|
||||
workspace: {
|
||||
overview: "Overview",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "Refresh capabilities",
|
||||
loading: "Loading Consul capabilities...",
|
||||
agentIdentity: "Agent identity",
|
||||
capabilities: "Capabilities",
|
||||
version: "Version",
|
||||
edition: "Edition",
|
||||
datacenter: "Datacenter",
|
||||
node: "Agent node",
|
||||
mode: "Agent mode",
|
||||
agent: "Agent API",
|
||||
acl: "ACL",
|
||||
namespaces: "Namespaces",
|
||||
partitions: "Admin Partitions",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "Supported",
|
||||
unsupported: "Unsupported",
|
||||
forbidden: "No permission",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
app: {
|
||||
@@ -458,6 +534,34 @@ export default {
|
||||
etcdClientCertBrowse: "Choose client certificate",
|
||||
etcdClientKeyBrowse: "Choose client private key",
|
||||
etcdClientCertPairRequired: "Client certificate and private key must be provided together.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "Connect String",
|
||||
zookeeperConnectStringHint: "Comma or line separated host:port entries, with an optional shared /chroot. Leave blank to use the host and port above.",
|
||||
zookeeperClusterInputHint: "For a ZooKeeper ensemble, enter all nodes either in the URL (for example zookeeper://zk-1:2181,zk-2:2181/app) or in Connect String below.",
|
||||
@@ -1140,6 +1244,7 @@ export default {
|
||||
etcdDashboard: "etcd Dashboard",
|
||||
etcdAccessControl: "etcd Access Control",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
gridfs: "GridFS",
|
||||
vector: "Vector",
|
||||
@@ -3739,6 +3844,7 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Path prefix, e.g. /app/",
|
||||
newKey: "New Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/es";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "Clave (Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "Renombrar",
|
||||
clone: "Clonar",
|
||||
copyKey: "Copiar clave",
|
||||
export: "Exportar",
|
||||
value: "Value",
|
||||
valueContent: "Valor",
|
||||
format: "Formato",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "Vigilar",
|
||||
stopWatching: "Detener",
|
||||
workspace: {
|
||||
overview: "Resumen",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "Actualizar capacidades",
|
||||
loading: "Cargando capacidades de Consul...",
|
||||
agentIdentity: "Identidad del agente",
|
||||
capabilities: "Capacidades",
|
||||
version: "Versión",
|
||||
edition: "Edición",
|
||||
datacenter: "Centro de datos",
|
||||
node: "Nodo del agente",
|
||||
mode: "Modo del agente",
|
||||
agent: "API del agente",
|
||||
acl: "ACL",
|
||||
namespaces: "Espacios de nombres",
|
||||
partitions: "Particiones administrativas",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Servidor",
|
||||
clientMode: "Cliente",
|
||||
capability: {
|
||||
supported: "Compatible",
|
||||
unsupported: "No compatible",
|
||||
forbidden: "Sin permiso",
|
||||
unknown: "Desconocido",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -438,6 +514,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "Elegir certificado de cliente",
|
||||
etcdClientKeyBrowse: "Elegir clave privada de cliente",
|
||||
etcdClientCertPairRequired: "El certificado y la clave privada deben proporcionarse juntos.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "Cadena de conexión",
|
||||
zookeeperConnectStringHint: "Entradas host:puerto separadas por coma o por línea. Déjalo en blanco para usar el host y puerto de arriba.",
|
||||
zookeeperCreateModePersistent: "Persistente",
|
||||
@@ -1119,6 +1223,7 @@ export default withEnglishFallback({
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
vector: "Vector",
|
||||
users: "Usuarios y Privilegios",
|
||||
executionSummary: "Resumen",
|
||||
@@ -3586,6 +3691,7 @@ export default withEnglishFallback({
|
||||
leaseCreated: "Lease {id} creado",
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefijo de ruta, ej. /app/",
|
||||
newKey: "Nuevo Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/it";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "Chiave (Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "Rinomina",
|
||||
clone: "Clona",
|
||||
copyKey: "Copia chiave",
|
||||
export: "Esporta",
|
||||
value: "Value",
|
||||
valueContent: "Valore",
|
||||
format: "Formato",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "Monitora",
|
||||
stopWatching: "Interrompi",
|
||||
workspace: {
|
||||
overview: "Panoramica",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "Aggiorna capacità",
|
||||
loading: "Caricamento delle capacità Consul...",
|
||||
agentIdentity: "Identità agent",
|
||||
capabilities: "Capacità",
|
||||
version: "Versione",
|
||||
edition: "Edizione",
|
||||
datacenter: "Datacenter",
|
||||
node: "Nodo agent",
|
||||
mode: "Modalità agent",
|
||||
agent: "API agent",
|
||||
acl: "ACL",
|
||||
namespaces: "Namespace",
|
||||
partitions: "Partizioni amministrative",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "Supportato",
|
||||
unsupported: "Non supportato",
|
||||
forbidden: "Nessun permesso",
|
||||
unknown: "Sconosciuto",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
name: "DBX",
|
||||
@@ -437,6 +513,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "Scegli certificato client",
|
||||
etcdClientKeyBrowse: "Scegli chiave privata client",
|
||||
etcdClientCertPairRequired: "Il certificato client e la chiave privata devono essere forniti insieme.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "Stringa di connessione",
|
||||
zookeeperConnectStringHint: "Voci host:porta separate da virgola o da riga. Lascia vuoto per usare l'host e la porta sopra.",
|
||||
zookeeperCreateModePersistent: "Persistente",
|
||||
@@ -1117,6 +1221,7 @@ export default withEnglishFallback({
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
vector: "Vector",
|
||||
objects: "Oggetti",
|
||||
@@ -3584,6 +3689,7 @@ export default withEnglishFallback({
|
||||
leaseCreated: "Lease {id} creato",
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefisso percorso, es. /app/",
|
||||
newKey: "Nuovo Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/ja";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "キー(Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "名前を変更",
|
||||
clone: "複製",
|
||||
copyKey: "Keyをコピー",
|
||||
export: "エクスポート",
|
||||
value: "Value",
|
||||
valueContent: "値",
|
||||
format: "形式",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "監視",
|
||||
stopWatching: "停止",
|
||||
workspace: {
|
||||
overview: "概要",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "機能情報を更新",
|
||||
loading: "Consul機能を読み込み中...",
|
||||
agentIdentity: "Agent情報",
|
||||
capabilities: "機能",
|
||||
version: "バージョン",
|
||||
edition: "エディション",
|
||||
datacenter: "データセンター",
|
||||
node: "Agentノード",
|
||||
mode: "Agentモード",
|
||||
agent: "Agent API",
|
||||
acl: "ACL",
|
||||
namespaces: "名前空間",
|
||||
partitions: "管理パーティション",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "対応",
|
||||
unsupported: "非対応",
|
||||
forbidden: "権限なし",
|
||||
unknown: "不明",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -705,6 +781,34 @@ export default withEnglishFallback({
|
||||
colorBlue: "青",
|
||||
colorPurple: "紫",
|
||||
colorCustom: "カスタム色",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "接続文字列",
|
||||
zookeeperConnectStringHint: "カンマまたは改行区切りの host:port エントリ。空欄の場合は上のホストとポートを使用します。",
|
||||
zookeeperCreateModePersistent: "永続",
|
||||
@@ -1181,6 +1285,7 @@ export default withEnglishFallback({
|
||||
fixedTabs: "固定タブ",
|
||||
openDataTabs: "開いているテーブル",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
vector: "ベクター",
|
||||
gridfs: "GridFS",
|
||||
etcdDashboard: "etcd ダッシュボード",
|
||||
@@ -2670,6 +2775,7 @@ export default withEnglishFallback({
|
||||
gridfs: "GridFS",
|
||||
buckets: "バケット",
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "パスプレフィックス(例: /app/)",
|
||||
newKey: "新しいZnode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/ko";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "키(Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "이름 변경",
|
||||
clone: "복제",
|
||||
copyKey: "Key 복사",
|
||||
export: "내보내기",
|
||||
value: "Value",
|
||||
valueContent: "값",
|
||||
format: "형식",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "감시",
|
||||
stopWatching: "중지",
|
||||
workspace: {
|
||||
overview: "개요",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "기능 정보 새로 고침",
|
||||
loading: "Consul 기능을 불러오는 중...",
|
||||
agentIdentity: "Agent 정보",
|
||||
capabilities: "기능",
|
||||
version: "버전",
|
||||
edition: "에디션",
|
||||
datacenter: "데이터센터",
|
||||
node: "Agent 노드",
|
||||
mode: "Agent 모드",
|
||||
agent: "Agent API",
|
||||
acl: "ACL",
|
||||
namespaces: "네임스페이스",
|
||||
partitions: "관리 파티션",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "지원",
|
||||
unsupported: "지원 안 함",
|
||||
forbidden: "권한 없음",
|
||||
unknown: "알 수 없음",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -452,6 +528,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "클라이언트 인증서 선택",
|
||||
etcdClientKeyBrowse: "클라이언트 개인 키 선택",
|
||||
etcdClientCertPairRequired: "클라이언트 인증서와 개인 키를 함께 제공해야 합니다.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "연결 문자열",
|
||||
zookeeperConnectStringHint: "쉼표 또는 줄바꿈으로 구분된 host:port 항목. 비워두면 위의 호스트와 포트를 사용합니다.",
|
||||
zookeeperCreateModePersistent: "영구",
|
||||
@@ -1029,6 +1133,7 @@ export default withEnglishFallback({
|
||||
etcd: "etcd",
|
||||
etcdDashboard: "etcd 대시보드",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
gridfs: "GridFS",
|
||||
vector: "Vector",
|
||||
@@ -3262,6 +3367,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "경로 접두사, 예: /app/",
|
||||
newKey: "새 Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/pt-BR";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key prefix, e.g. app/ or services/",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
empty: "No keys found",
|
||||
loadMore: "Load more",
|
||||
selectKey: "Select a key to view its value",
|
||||
loadingValue: "Loading value...",
|
||||
notFound: "Key not found",
|
||||
edit: "Edit",
|
||||
editKey: "Edit Key",
|
||||
delete: "Delete",
|
||||
deleteTitle: "Delete Consul KV key",
|
||||
keyLabel: "Chave (Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key is required",
|
||||
rootReadonly: "The root prefix cannot be modified. Enter a key path.",
|
||||
saved: "Key saved",
|
||||
deleted: "Key deleted",
|
||||
base64Readonly: "Edit binary values in Base64 mode.",
|
||||
rename: "Renomear",
|
||||
clone: "Clonar",
|
||||
copyKey: "Copiar chave",
|
||||
export: "Exportar",
|
||||
value: "Value",
|
||||
valueContent: "Valor",
|
||||
format: "Formato",
|
||||
metadata: "KV metadata",
|
||||
prettyJson: "Pretty",
|
||||
invalidJson: "Invalid JSON",
|
||||
summarySize: "Size",
|
||||
conflict: "This Key changed after it was loaded. Refresh it and try again.",
|
||||
keyAlreadyExists: "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
aclFiltered: "Some Consul KV keys are hidden by ACL policies.",
|
||||
locked: "This Key is held by a Consul Session and cannot be edited or deleted.",
|
||||
valueTooLarge: "Consul KV values cannot exceed 512 KiB.",
|
||||
watchKey: "Monitorar",
|
||||
stopWatching: "Parar",
|
||||
workspace: {
|
||||
overview: "Visão geral",
|
||||
kv: "KV",
|
||||
services: "Services",
|
||||
health: "Health",
|
||||
sessions: "Sessions",
|
||||
scope: "Scope",
|
||||
mesh: "Service Mesh",
|
||||
tools: "Tools",
|
||||
operator: "Operator",
|
||||
refresh: "Atualizar recursos",
|
||||
loading: "Carregando recursos do Consul...",
|
||||
agentIdentity: "Identidade do agente",
|
||||
capabilities: "Recursos",
|
||||
version: "Versão",
|
||||
edition: "Edição",
|
||||
datacenter: "Datacenter",
|
||||
node: "Nó do agente",
|
||||
mode: "Modo do agente",
|
||||
agent: "API do agente",
|
||||
acl: "ACL",
|
||||
namespaces: "Namespaces",
|
||||
partitions: "Partições administrativas",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Servidor",
|
||||
clientMode: "Cliente",
|
||||
capability: {
|
||||
supported: "Compatível",
|
||||
unsupported: "Não compatível",
|
||||
forbidden: "Sem permissão",
|
||||
unknown: "Desconhecido",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -438,6 +514,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "Escolher certificado do cliente",
|
||||
etcdClientKeyBrowse: "Escolher chave privada do cliente",
|
||||
etcdClientCertPairRequired: "O certificado e a chave privada do cliente devem ser fornecidos juntos.",
|
||||
consulAddress: "Consul API address",
|
||||
consulAddressInvalid: "Enter an absolute HTTP or HTTPS Consul API address without credentials, query parameters, or a fragment.",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "Optional Consul ACL Token",
|
||||
consulDatacenter: "Datacenter",
|
||||
consulNamespace: "Namespace",
|
||||
consulPartition: "Admin Partition",
|
||||
consulAgentTargetNode: "Agent target node",
|
||||
consulAgentTargetAddress: "Agent direct address",
|
||||
consulAgentTargetHint: "Required for Agent writes. DNS/load-balanced and Transport Layer endpoints stay read-only.",
|
||||
consulMeshFeatures: "Service mesh",
|
||||
consulMeshVisible: "Show advanced service mesh workspace",
|
||||
consulMeshVisibleHint: "Enable only to manage Connect, intentions, or peering. When disabled, the workspace stays hidden.",
|
||||
consulOperatorWrites: "Operator writes",
|
||||
consulOperatorVisible: "Show experimental Operator workspace",
|
||||
consulOperatorSnapshotRestore: "Enable snapshot restore",
|
||||
consulOperatorAutopilot: "Enable Autopilot updates",
|
||||
consulOperatorRaft: "Enable Raft changes",
|
||||
consulOperatorKeyring: "Enable Keyring changes",
|
||||
consulOperatorLicense: "Enable License updates",
|
||||
consulAgentTargetIncomplete: "Agent target node and direct address must be configured together.",
|
||||
consulAgentTargetAddressMismatch: "Agent direct address must match the Consul endpoint host.",
|
||||
consulConsistency: "Read consistency",
|
||||
consulConsistencyDefault: "Default",
|
||||
consulConsistencyStale: "Stale",
|
||||
consulConsistencyConsistent: "Consistent",
|
||||
consulTlsSkipVerify: "Skip certificate verification",
|
||||
consulTlsSkipVerifyHint: "Use only for trusted self-signed Consul endpoints.",
|
||||
zookeeperConnectString: "String de Conexão",
|
||||
zookeeperConnectStringHint: "Entradas host:porta separadas por vírgula ou linha. Deixe em branco para usar o host e porta acima.",
|
||||
zookeeperCreateModePersistent: "Persistente",
|
||||
@@ -1118,6 +1222,7 @@ export default withEnglishFallback({
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
vector: "Vector",
|
||||
objects: "Objetos",
|
||||
@@ -3586,6 +3691,7 @@ export default withEnglishFallback({
|
||||
leaseCreated: "Lease {id} criado",
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "Prefixo de caminho, ex.: /app/",
|
||||
newKey: "Novo Znode",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/zh-CN";
|
||||
import { consul } from "./consulZhCN";
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -459,6 +460,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "选择客户端证书",
|
||||
etcdClientKeyBrowse: "选择客户端私钥",
|
||||
etcdClientCertPairRequired: "客户端证书和私钥必须一起填写。",
|
||||
consulAddress: "Consul API 地址",
|
||||
consulAddressInvalid: "请输入不包含凭据、查询参数或片段的 HTTP/HTTPS Consul API 绝对地址。",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "可选的 Consul ACL Token",
|
||||
consulDatacenter: "数据中心",
|
||||
consulNamespace: "命名空间",
|
||||
consulPartition: "管理分区",
|
||||
consulAgentTargetNode: "目标 Agent 节点",
|
||||
consulAgentTargetAddress: "Agent 直连地址",
|
||||
consulAgentTargetHint: "Agent 写操作必填。DNS/负载均衡和 Transport Layer 连接保持只读。",
|
||||
consulMeshFeatures: "服务网格",
|
||||
consulMeshVisible: "显示服务网格高级功能",
|
||||
consulMeshVisibleHint: "仅在需要管理 Connect、服务意图或对等连接时开启;关闭后主导航不会显示服务网格。",
|
||||
consulOperatorWrites: "Operator 写入",
|
||||
consulOperatorVisible: "显示实验性 Operator 工作台",
|
||||
consulOperatorSnapshotRestore: "启用 Snapshot 恢复",
|
||||
consulOperatorAutopilot: "启用 Autopilot 修改",
|
||||
consulOperatorRaft: "启用 Raft 修改",
|
||||
consulOperatorKeyring: "启用 Keyring 修改",
|
||||
consulOperatorLicense: "启用 License 修改",
|
||||
consulAgentTargetIncomplete: "目标 Agent 节点和直连地址必须同时配置。",
|
||||
consulAgentTargetAddressMismatch: "Agent 直连地址必须与 Consul 地址的主机一致。",
|
||||
consulConsistency: "读取一致性",
|
||||
consulConsistencyDefault: "默认",
|
||||
consulConsistencyStale: "允许陈旧读取",
|
||||
consulConsistencyConsistent: "强一致读取",
|
||||
consulTlsSkipVerify: "跳过证书校验",
|
||||
consulTlsSkipVerifyHint: "仅用于可信的自签名 Consul 服务端。",
|
||||
zookeeperConnectString: "Connect String",
|
||||
zookeeperConnectStringHint: "可用逗号或换行分隔 host:port,并可在末尾填写整个集群共用的 /chroot;留空时使用上面的 host 和端口。",
|
||||
zookeeperClusterInputHint: "ZooKeeper 集群可在上方 URL 中填写全部节点(例如 zookeeper://zk-1:2181,zk-2:2181/app),也可在下方 Connect String 中填写。",
|
||||
@@ -1140,6 +1169,7 @@ export default withEnglishFallback({
|
||||
etcdDashboard: "etcd 大盘",
|
||||
etcdAccessControl: "etcd 访问控制",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
gridfs: "GridFS",
|
||||
vector: "向量",
|
||||
@@ -3357,7 +3387,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
etcd: {
|
||||
prefixPlaceholder: "搜索 Key 路径,例如 /app/ 或 service",
|
||||
newKey: "新建 Key",
|
||||
newKey: "新建键(Key)",
|
||||
loadingKeys: "正在加载 Key...",
|
||||
empty: "未找到 Key",
|
||||
loadMore: "加载更多",
|
||||
@@ -3737,6 +3767,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "路径前缀,例如 /app/",
|
||||
newKey: "新建 Znode",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/zh-TW";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
prefixPlaceholder: "Key 前綴,例如 app/ 或 services/",
|
||||
newKey: "新增 Key",
|
||||
loadingKeys: "正在載入 Key...",
|
||||
empty: "找不到 Key",
|
||||
loadMore: "載入更多",
|
||||
selectKey: "選擇一個 Key 以檢視其值",
|
||||
loadingValue: "正在載入值...",
|
||||
notFound: "找不到 Key",
|
||||
edit: "編輯",
|
||||
editKey: "編輯 Key",
|
||||
delete: "刪除",
|
||||
deleteTitle: "刪除 Consul KV Key",
|
||||
keyLabel: "鍵(Key)",
|
||||
keyPlaceholder: "path/to/key",
|
||||
keyRequired: "Key 不能為空",
|
||||
rootReadonly: "根前綴無法修改,請輸入具體 Key 路徑。",
|
||||
saved: "Key 已儲存",
|
||||
deleted: "Key 已刪除",
|
||||
base64Readonly: "請在 Base64 模式下編輯二進位值。",
|
||||
rename: "重新命名",
|
||||
clone: "複製",
|
||||
copyKey: "複製 Key",
|
||||
export: "匯出",
|
||||
value: "值",
|
||||
valueContent: "值",
|
||||
format: "格式",
|
||||
metadata: "KV 中繼資料",
|
||||
prettyJson: "格式化",
|
||||
invalidJson: "JSON 格式無效",
|
||||
summarySize: "大小",
|
||||
conflict: "此 Key 自載入後已變更,請重新整理後再試。",
|
||||
keyAlreadyExists: "同名 Key 已存在,請使用其他名稱或編輯現有 Key。",
|
||||
aclFiltered: "部分 Consul KV Key 已被 ACL 原則過濾。",
|
||||
locked: "此 Key 正由 Consul Session 持有,無法編輯或刪除。",
|
||||
valueTooLarge: "Consul KV 值不能超過 512 KiB。",
|
||||
watchKey: "監視",
|
||||
stopWatching: "停止",
|
||||
workspace: {
|
||||
overview: "概覽",
|
||||
kv: "KV",
|
||||
services: "服務",
|
||||
health: "健康檢查",
|
||||
sessions: "Session",
|
||||
scope: "作用域",
|
||||
mesh: "Service Mesh",
|
||||
tools: "工具",
|
||||
operator: "Operator",
|
||||
refresh: "重新整理能力資訊",
|
||||
loading: "正在載入 Consul 能力...",
|
||||
agentIdentity: "Agent 身分",
|
||||
capabilities: "能力狀態",
|
||||
version: "版本",
|
||||
edition: "版本類型",
|
||||
datacenter: "資料中心",
|
||||
node: "Agent 節點",
|
||||
mode: "Agent 模式",
|
||||
agent: "Agent API",
|
||||
acl: "ACL",
|
||||
namespaces: "命名空間",
|
||||
partitions: "管理分區",
|
||||
enterprise: "Enterprise",
|
||||
community: "Community",
|
||||
serverMode: "Server",
|
||||
clientMode: "Client",
|
||||
capability: {
|
||||
supported: "支援",
|
||||
unsupported: "不支援",
|
||||
forbidden: "無權限",
|
||||
unknown: "未知",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
app: {
|
||||
@@ -438,6 +514,34 @@ export default withEnglishFallback({
|
||||
etcdClientCertBrowse: "選擇用戶端憑證",
|
||||
etcdClientKeyBrowse: "選擇用戶端私鑰",
|
||||
etcdClientCertPairRequired: "用戶端憑證和私鑰必須一起填寫。",
|
||||
consulAddress: "Consul API 位址",
|
||||
consulAddressInvalid: "請輸入不包含憑證、查詢參數或片段的 HTTP/HTTPS Consul API 絕對位址。",
|
||||
consulToken: "ACL Token",
|
||||
consulTokenPlaceholder: "選填的 Consul ACL Token",
|
||||
consulDatacenter: "資料中心",
|
||||
consulNamespace: "命名空間",
|
||||
consulPartition: "管理分區",
|
||||
consulAgentTargetNode: "目標 Agent 節點",
|
||||
consulAgentTargetAddress: "Agent 直連位址",
|
||||
consulAgentTargetHint: "Agent 寫入操作必填。DNS/負載均衡和 Transport Layer 連線維持唯讀。",
|
||||
consulMeshFeatures: "服務網格",
|
||||
consulMeshVisible: "顯示服務網格進階功能",
|
||||
consulMeshVisibleHint: "僅在需要管理 Connect、服務意圖或對等連線時開啟;關閉後主導覽不會顯示服務網格。",
|
||||
consulOperatorWrites: "Operator 寫入",
|
||||
consulOperatorVisible: "顯示實驗性 Operator 工作台",
|
||||
consulOperatorSnapshotRestore: "啟用 Snapshot 還原",
|
||||
consulOperatorAutopilot: "啟用 Autopilot 修改",
|
||||
consulOperatorRaft: "啟用 Raft 修改",
|
||||
consulOperatorKeyring: "啟用 Keyring 修改",
|
||||
consulOperatorLicense: "啟用 License 修改",
|
||||
consulAgentTargetIncomplete: "目標 Agent 節點和直連位址必須同時設定。",
|
||||
consulAgentTargetAddressMismatch: "Agent 直連位址必須與 Consul 位址的主機一致。",
|
||||
consulConsistency: "讀取一致性",
|
||||
consulConsistencyDefault: "預設",
|
||||
consulConsistencyStale: "允許陳舊讀取",
|
||||
consulConsistencyConsistent: "強一致讀取",
|
||||
consulTlsSkipVerify: "略過憑證驗證",
|
||||
consulTlsSkipVerifyHint: "僅用於可信任的自簽章 Consul 端點。",
|
||||
zookeeperConnectString: "連線字串",
|
||||
zookeeperConnectStringHint: "以逗號或換行分隔的 host:port 項目。留空則使用上方的主機和連接埠。",
|
||||
zookeeperCreateModePersistent: "持久",
|
||||
@@ -1117,6 +1221,7 @@ export default withEnglishFallback({
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "ZooKeeper",
|
||||
consul: "Consul",
|
||||
mongo: "Mongo",
|
||||
vector: "向量",
|
||||
objects: "物件",
|
||||
@@ -6036,6 +6141,7 @@ export default withEnglishFallback({
|
||||
leaseCreated: "已建立 Lease {id}",
|
||||
},
|
||||
},
|
||||
consul,
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "路徑前綴,例如 /app/",
|
||||
newKey: "新增 Znode",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
function section(startMarker: string, endMarker: string): string {
|
||||
const start = dialogSource.indexOf(startMarker);
|
||||
const end = dialogSource.indexOf(endMarker, start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return dialogSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe("Consul connection dialog", () => {
|
||||
it("keeps Operator feature switches in the Consul form instead of the JDBC form", () => {
|
||||
const jdbcForm = section("<!-- JDBC: optional external plugin -->", "<!-- Local database files: file path only -->");
|
||||
const consulForm = section("<!-- Consul KV: HTTP endpoint, ACL token and scope -->", "<!-- etcd: endpoints, user, password, TLS -->");
|
||||
const operatorModels = ["consulOperatorVisible", "consulOperatorSnapshotRestoreEnabled", "consulOperatorAutopilotWriteEnabled", "consulOperatorRaftWriteEnabled", "consulOperatorKeyringWriteEnabled", "consulOperatorLicenseWriteEnabled"];
|
||||
|
||||
for (const model of operatorModels) {
|
||||
expect(jdbcForm).not.toContain(`v-model="${model}"`);
|
||||
expect(consulForm).toContain(`v-model="${model}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLazyKvKeyTreeState, replaceLazyKvChildren } from "@/lib/kv/slashPrefixLazyKeyTree";
|
||||
|
||||
describe("slashPrefixLazyKeyTree", () => {
|
||||
it("treats a trailing-slash Consul Key with no children as a leaf after expansion", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
replaceLazyKvChildren(state, null, [{ key: "feature/", hasValue: true }], null);
|
||||
|
||||
const node = state.nodeByKey.get("feature/");
|
||||
expect(node?.hasChildren).toBe(true);
|
||||
|
||||
replaceLazyKvChildren(state, "feature/", [], null, { filteredByAcls: false });
|
||||
expect(state.nodeByKey.get("feature/")?.hasChildren).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the directory affordance when ACL filtering makes an empty listing inconclusive", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
replaceLazyKvChildren(state, null, [{ key: "feature/", hasValue: true }], null);
|
||||
|
||||
replaceLazyKvChildren(state, "feature/", [], null, { filteredByAcls: true });
|
||||
expect(state.nodeByKey.get("feature/")?.hasChildren).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ describe("sidebar tree item layout", () => {
|
||||
expect(canTreeNodeShowExpander({ type: "etcd-root", childCount: 0 })).toBe(false);
|
||||
expect(canTreeNodeShowExpander({ type: "etcd-dashboard", childCount: 0 })).toBe(false);
|
||||
expect(canTreeNodeShowExpander({ type: "etcd-access-control", childCount: 0 })).toBe(false);
|
||||
expect(canTreeNodeShowExpander({ type: "consul-overview", childCount: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("aligns comments to the longest sibling name without crossing parent groups", () => {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { objectSourceKindForTreeNode, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, shouldActivateTreeNodeOnSingleClick, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick";
|
||||
|
||||
describe("treeNodeClick", () => {
|
||||
it("opens synonym nodes as synonym source", () => {
|
||||
expect(objectSourceKindForTreeNode("synonym")).toBe("SYNONYM");
|
||||
expect(treeNodeRowAction("synonym", false)).toBe("open-source");
|
||||
});
|
||||
|
||||
it("opens Consul navigation entries on one click regardless of object activation preference", () => {
|
||||
expect(isDirectNavigationTreeNode("consul-root")).toBe(true);
|
||||
expect(isDirectNavigationTreeNode("consul-overview")).toBe(true);
|
||||
expect(isDirectNavigationTreeNode("table")).toBe(false);
|
||||
expect(shouldActivateTreeNodeOnSingleClick("consul-root", "double")).toBe(true);
|
||||
expect(shouldActivateTreeNodeOnSingleClick("consul-overview", "double")).toBe(true);
|
||||
expect(shouldActivateTreeNodeOnSingleClick("table", "double")).toBe(false);
|
||||
expect(treeNodeRowAction("consul-root", false, "double")).toBe("toggle");
|
||||
expect(treeNodeRowAction("consul-overview", false, "double")).toBe("toggle");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLazyKvKeyTreeState, createZooKeeperChildPathDraft, flattenLazyKvKeyTree, replaceLazyKvChildren, resetLazyKvKeyTree } from "@/lib/zookeeper/zookeeperLazyKeyTree";
|
||||
import { createLazyKvKeyTreeState, createZooKeeperChildPathDraft, flattenLazyKvKeyTree, replaceLazyKvChildren, replaceLazyKvFocusedRoot, resetLazyKvKeyTree } from "@/lib/zookeeper/zookeeperLazyKeyTree";
|
||||
|
||||
describe("zookeeper lazy key tree", () => {
|
||||
it("stores only the current path direct children on reset", () => {
|
||||
@@ -49,4 +49,41 @@ describe("zookeeper lazy key tree", () => {
|
||||
expect(createZooKeeperChildPathDraft("/app")).toBe("/app/");
|
||||
expect(createZooKeeperChildPathDraft("app/")).toBe("/app/");
|
||||
});
|
||||
|
||||
it("keeps an exact focused Key without children as a leaf", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
|
||||
replaceLazyKvFocusedRoot(state, { key: "dbx-demo/locks/persistent", numChildren: 0, valueSize: 12, hasValue: true }, [], null);
|
||||
|
||||
const leaf = state.nodeByKey.get("dbx-demo/locks/persistent");
|
||||
expect(leaf?.hasChildren).toBe(false);
|
||||
expect(leaf?.hasValue).toBe(true);
|
||||
expect(leaf?.label).toBe("persistent");
|
||||
});
|
||||
|
||||
it("keeps a real Consul Key and its child prefix as one dual-purpose node", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
|
||||
replaceLazyKvFocusedRoot(state, { key: "applications", numChildren: 1, valueSize: 7, hasValue: true }, [{ key: "applications/api", numChildren: 0, valueSize: 3 }], null);
|
||||
|
||||
const root = state.nodeByKey.get("applications");
|
||||
expect(root?.hasValue).toBe(true);
|
||||
expect(root?.hasChildren).toBe(true);
|
||||
expect(root?.children.map((child) => child.key)).toEqual(["applications/api"]);
|
||||
});
|
||||
|
||||
it("requires an exact GET before treating a trailing-slash Consul path as a Key", () => {
|
||||
const state = createLazyKvKeyTreeState("", "relative");
|
||||
|
||||
replaceLazyKvChildren(state, null, [{ key: "applications/", numChildren: 2 }], null);
|
||||
|
||||
const prefix = state.nodeByKey.get("applications/");
|
||||
expect(prefix?.hasChildren).toBe(true);
|
||||
expect(prefix?.hasValue).toBeNull();
|
||||
|
||||
replaceLazyKvFocusedRoot(state, { key: "applications/", numChildren: 0, hasValue: true }, [], null);
|
||||
const exactTrailingSlashKey = state.nodeByKey.get("applications/");
|
||||
expect(exactTrailingSlashKey?.hasChildren).toBe(false);
|
||||
expect(exactTrailingSlashKey?.hasValue).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Scope = "global" | "read" | "write";
|
||||
type Method = "GET" | "PUT" | "POST" | "DELETE";
|
||||
type Contract = { domain: string; file: string; method: Method; path: string; needle?: string; methodNeedle?: string; scopeNeedle?: string; scope: Scope };
|
||||
|
||||
const contracts: Contract[] = [
|
||||
{ domain: "capabilities", file: "capabilities.rs", method: "GET", path: "/v1/agent/self", scope: "global" },
|
||||
{ domain: "capabilities", file: "capabilities.rs", method: "GET", path: "/v1/acl/token/self", scope: "read" },
|
||||
{ domain: "kv", file: "kv.rs", method: "GET", path: "/v1/kv/{key-or-prefix}", needle: "/v1/kv/{encoded}", scope: "read" },
|
||||
{ domain: "kv", file: "kv.rs", method: "PUT", path: "/v1/kv/{key}", needle: "/v1/kv/{encoded}", scope: "write" },
|
||||
{ domain: "kv", file: "kv.rs", method: "DELETE", path: "/v1/kv/{key}", needle: "/v1/kv/{encoded}", scope: "write" },
|
||||
{ domain: "txn", file: "txn.rs", method: "PUT", path: "/v1/txn", scope: "write" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/kv/{key-or-prefix}", needle: "/v1/kv/{encoded}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/catalog/nodes", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/catalog/services", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/catalog/service/{service}", needle: "/v1/catalog/service/{}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/catalog/node/{node}", needle: "/v1/catalog/node/{}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/health/node/{node}", needle: "/v1/health/node/{}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/health/checks/{service}", needle: "/v1/health/checks/{}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/health/service/{service}", needle: "/v1/health/service/{}{suffix}", scope: "read" },
|
||||
{ domain: "blocking", file: "blocking.rs", method: "GET", path: "/v1/health/state/{state}", needle: "/v1/health/state/{}", scope: "read" },
|
||||
{ domain: "status", file: "status.rs", method: "GET", path: "/v1/status/leader", scope: "global" },
|
||||
{ domain: "status", file: "status.rs", method: "GET", path: "/v1/status/peers", scope: "global" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/datacenters", scope: "global" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/nodes", scope: "read" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/services", scope: "read" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/service/{service}", needle: "/v1/catalog/service/{}", scope: "read" },
|
||||
{ domain: "catalog", file: "catalog.rs", method: "GET", path: "/v1/catalog/node/{node}", needle: "/v1/catalog/node/{}", scope: "read" },
|
||||
{ domain: "health", file: "health.rs", method: "GET", path: "/v1/health/node/{node}", needle: "/v1/health/node/{}", methodNeedle: "read_list(", scope: "read" },
|
||||
{ domain: "health", file: "health.rs", method: "GET", path: "/v1/health/checks/{service}", needle: "/v1/health/checks/{}", methodNeedle: "read_list(", scope: "read" },
|
||||
{ domain: "health", file: "health.rs", method: "GET", path: "/v1/health/service/{service}", needle: "/v1/health/service/{}", methodNeedle: "read_list(", scope: "read" },
|
||||
{ domain: "health", file: "health.rs", method: "GET", path: "/v1/health/state/{state}", needle: "/v1/health/state/{state}", methodNeedle: "read_list(", scope: "read" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/self", scope: "global" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/members", scope: "global" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/metrics", scope: "global" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/services", scope: "read" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/service/{id}", needle: "/v1/agent/service/{}", scope: "read" },
|
||||
{ domain: "agent", file: "agent.rs", method: "GET", path: "/v1/agent/checks", scope: "read" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/service/register", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/service/deregister/{id}", needle: "/v1/agent/service/deregister/{}", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/service/maintenance/{id}", needle: "/v1/agent/service/maintenance/{}", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/check/register", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/check/deregister/{id}", needle: "/v1/agent/check/deregister/{}", scope: "write" },
|
||||
{ domain: "agent", file: "agent.rs", method: "PUT", path: "/v1/agent/check/update/{id}", needle: "/v1/agent/check/update/{}", scope: "write" },
|
||||
{ domain: "session", file: "session.rs", method: "GET", path: "/v1/session/list", scope: "read" },
|
||||
{ domain: "session", file: "session.rs", method: "GET", path: "/v1/session/node/{node}", needle: "/v1/session/node/{}", scope: "read" },
|
||||
{ domain: "session", file: "session.rs", method: "GET", path: "/v1/session/info/{id}", needle: "/v1/session/info/{}", scope: "read" },
|
||||
{ domain: "session", file: "session.rs", method: "PUT", path: "/v1/session/create", scope: "write" },
|
||||
{ domain: "session", file: "session.rs", method: "PUT", path: "/v1/session/renew/{id}", needle: "/v1/session/renew/{}", scope: "write" },
|
||||
{ domain: "session", file: "session.rs", method: "PUT", path: "/v1/session/destroy/{id}", needle: "/v1/session/destroy/{}", scope: "write" },
|
||||
{ domain: "acl", file: "acl.rs", method: "GET", path: "/v1/acl/token/self", scope: "read" },
|
||||
{ domain: "acl", file: "acl.rs", method: "PUT", path: "/v1/acl/token/{id}/clone", needle: "/v1/acl/token/{accessor_id}/clone", scope: "write" },
|
||||
{ domain: "acl", file: "acl.rs", method: "GET", path: "/v1/acl/{tokens|policies|roles|auth-methods|binding-rules|templated-policies}", needle: "/v1/acl/tokens", scope: "read" },
|
||||
{ domain: "acl", file: "acl.rs", method: "PUT", path: "/v1/acl/{token|policy|role|auth-method|binding-rule}", needle: "/v1/acl/token", scope: "write" },
|
||||
{ domain: "acl", file: "acl.rs", method: "PUT", path: "/v1/acl/{kind}/{id}", needle: "(Method::PUT, item_path", scope: "write" },
|
||||
{ domain: "acl", file: "acl.rs", method: "DELETE", path: "/v1/acl/{kind}/{id}", needle: "item_path(kind, id)", scope: "write" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "GET", path: "/v1/namespaces", scope: "read" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "GET", path: "/v1/partitions", scope: "read" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "GET", path: "/v1/{namespace|partition}/{name}", needle: "/v1/namespace/{name}", scope: "read" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "PUT", path: "/v1/{namespace|partition}", needle: "/v1/namespace", scope: "write" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "PUT", path: "/v1/{namespace|partition}/{name}", needle: "Some(name) => (Method::PUT", scope: "write" },
|
||||
{ domain: "enterprise", file: "enterprise.rs", method: "DELETE", path: "/v1/{namespace|partition}/{name}", needle: "client.send(Method::DELETE", scopeNeedle: "append_enterprise_target", scope: "write" },
|
||||
{ domain: "mesh-config", file: "mesh/config_entries.rs", method: "GET", path: "/v1/config/{kind}", needle: "/v1/config/{}", scope: "read" },
|
||||
{ domain: "mesh-config", file: "mesh/config_entries.rs", method: "GET", path: "/v1/config/{kind}/{name}", needle: "/v1/config/{}/{}", scope: "read" },
|
||||
{ domain: "mesh-config", file: "mesh/config_entries.rs", method: "PUT", path: "/v1/config", scope: "write" },
|
||||
{ domain: "mesh-config", file: "mesh/config_entries.rs", method: "DELETE", path: "/v1/config/{kind}/{name}", needle: "config_path(kind, name)", scope: "write" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions", scope: "read" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions/{id}", needle: "/v1/connect/intentions/{}", scope: "read" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions/exact", scope: "read" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "PUT", path: "/v1/connect/intentions/exact", scope: "write" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "DELETE", path: "/v1/connect/intentions/{id|exact}", needle: "send_json(Method::DELETE", scope: "write" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions/match", scope: "read" },
|
||||
{ domain: "intentions", file: "mesh/intentions.rs", method: "GET", path: "/v1/connect/intentions/check", scope: "read" },
|
||||
{ domain: "discovery", file: "mesh/discovery.rs", method: "GET", path: "/v1/discovery-chain/{service}", needle: "/v1/discovery-chain/{service}", scope: "read" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "GET", path: "/v1/peerings", scope: "read" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "GET", path: "/v1/peering/{name}", needle: "/v1/peering/{}", scope: "read" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "POST", path: "/v1/peering/token", scope: "write" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "POST", path: "/v1/peering/establish", scope: "write" },
|
||||
{ domain: "peering", file: "mesh/peering.rs", method: "DELETE", path: "/v1/peering/{name}", needle: "send_json(Method::DELETE", scope: "write" },
|
||||
{ domain: "exported-services", file: "mesh/exported_services.rs", method: "GET", path: "/v1/exported-services", scope: "read" },
|
||||
{ domain: "exported-services", file: "mesh/exported_services.rs", method: "PUT", path: "/v1/config", needle: "consul_mesh_config_apply_core", methodNeedle: "consul_mesh_config_apply_core", scopeNeedle: "consul_mesh_config_apply_core", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/query", scope: "read" },
|
||||
{ domain: "tools", file: "tools.rs", method: "POST", path: "/v1/query", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "PUT", path: "/v1/query/{id}", needle: "/v1/query/{}", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "DELETE", path: "/v1/query/{id}", needle: "send_json(Method::DELETE", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/query/{id}/execute", needle: "/v1/query/{}/execute", scope: "read" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/query/{id}/explain", needle: "/v1/query/{}/explain", scope: "read" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/event/list", scope: "read" },
|
||||
{ domain: "tools", file: "tools.rs", method: "PUT", path: "/v1/event/fire/{name}", needle: "/v1/event/fire/{}", scope: "write" },
|
||||
{ domain: "tools", file: "tools.rs", method: "GET", path: "/v1/coordinate/nodes", scope: "read" },
|
||||
{ domain: "operator", file: "operator.rs", method: "GET", path: "/v1/operator/{autopilot|raft|usage|license}", needle: "/v1/operator/autopilot/configuration", scope: "read" },
|
||||
{ domain: "operator", file: "operator.rs", method: "POST", path: "/v1/operator/audit-hash", scope: "read" },
|
||||
{ domain: "operator", file: "operator.rs", method: "GET", path: "/v1/snapshot", scope: "read" },
|
||||
{ domain: "operator", file: "operator.rs", method: "PUT", path: "/v1/snapshot", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "PUT", path: "/v1/operator/autopilot/configuration", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "POST", path: "/v1/operator/raft/transfer-leader", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "DELETE", path: "/v1/operator/raft/peer", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "POST", path: "/v1/operator/keyring", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "PUT", path: "/v1/operator/keyring", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "DELETE", path: "/v1/operator/keyring", scope: "write" },
|
||||
{ domain: "operator", file: "operator.rs", method: "PUT", path: "/v1/operator/license", scope: "write" },
|
||||
];
|
||||
|
||||
const sourceCache = new Map<string, string>();
|
||||
function source(file: string): string {
|
||||
if (!sourceCache.has(file)) sourceCache.set(file, readFileSync(join(process.cwd(), "crates/dbx-core/src/consul", file), "utf8"));
|
||||
return sourceCache.get(file)!;
|
||||
}
|
||||
|
||||
const methodScopeSnapshot = {
|
||||
"kv.rs": [4, 3, 0, 1, 4, 4],
|
||||
"txn.rs": [0, 1, 0, 0, 0, 1],
|
||||
"blocking.rs": [2, 0, 0, 0, 2, 0],
|
||||
"status.rs": [2, 0, 0, 0, 0, 0],
|
||||
"catalog.rs": [2, 0, 0, 0, 5, 0],
|
||||
"health.rs": [0, 0, 0, 0, 4, 0],
|
||||
"agent.rs": [6, 2, 0, 0, 3, 2],
|
||||
"session.rs": [1, 3, 0, 0, 3, 3],
|
||||
"acl.rs": [9, 3, 0, 1, 9, 3],
|
||||
"enterprise.rs": [5, 2, 0, 1, 3, 2],
|
||||
"mesh/config_entries.rs": [2, 1, 0, 1, 2, 2],
|
||||
"mesh/intentions.rs": [6, 1, 0, 2, 6, 3],
|
||||
"mesh/discovery.rs": [1, 0, 0, 0, 1, 0],
|
||||
"mesh/peering.rs": [2, 0, 2, 1, 2, 3],
|
||||
"mesh/exported_services.rs": [1, 0, 0, 0, 1, 0],
|
||||
"tools.rs": [7, 2, 1, 1, 5, 3],
|
||||
"operator.rs": [2, 4, 3, 2, 3, 6],
|
||||
"capabilities.rs": [3, 0, 1, 0, 3, 0],
|
||||
} satisfies Record<string, [number, number, number, number, number, number]>;
|
||||
|
||||
describe("Consul Core endpoint contract", () => {
|
||||
it("enumerates every supported domain without duplicate rows", () => {
|
||||
expect(new Set(contracts.map(({ domain }) => domain))).toEqual(new Set(["capabilities", "kv", "txn", "blocking", "status", "catalog", "health", "agent", "session", "acl", "enterprise", "mesh-config", "intentions", "discovery", "peering", "exported-services", "tools", "operator"]));
|
||||
const identities = contracts.map(({ domain, method, path, scope }) => `${domain}:${method}:${path}:${scope}`);
|
||||
expect(new Set(identities).size).toBe(identities.length);
|
||||
});
|
||||
|
||||
it.each(contracts)("$domain $method $path uses $scope scope", ({ file, method, path, needle, methodNeedle, scopeNeedle, scope }) => {
|
||||
const code = source(file);
|
||||
const evidence = needle ?? path;
|
||||
expect(code, `${file} path evidence for ${path}`).toContain(evidence);
|
||||
expect(code, `${file} method evidence for ${method} ${path}`).toContain(methodNeedle ?? `Method::${method}`);
|
||||
if (scopeNeedle) expect(code, `${file} scope delegation for ${path}`).toContain(scopeNeedle);
|
||||
else if (scope === "read") expect(code).toMatch(/append_scope\(&mut url, true\)|request_json\([\s\S]*?true|send_json\([\s\S]*?true|read_list\(/);
|
||||
else if (scope === "write") expect(code).toMatch(/append_scope\(&mut url, false\)|request_json\([\s\S]*?false|send_json\([\s\S]*?false|ensure_writable/);
|
||||
});
|
||||
|
||||
it("centralizes scoped requests and ACL token headers", () => {
|
||||
const client = source("client.rs");
|
||||
expect(client).toContain("self.append_scope(&mut url, read);");
|
||||
expect(client).toContain('request.header("X-Consul-Token", &self.config.token)');
|
||||
expect(client.match(/request\.header\("X-Consul-Token"/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps gossip keys in the request body and maps each operation to its official method", () => {
|
||||
const operator = source("operator.rs");
|
||||
expect(operator).toContain("ConsulKeyringOperation::Install => Method::POST");
|
||||
expect(operator).toContain("ConsulKeyringOperation::Use => Method::PUT");
|
||||
expect(operator).toContain("ConsulKeyringOperation::Remove => Method::DELETE");
|
||||
expect(operator).toContain("ConsulKeyringWriteBody { key: request.key.trim() }");
|
||||
expect(operator).not.toMatch(/query_pairs_mut\(\)\.append_pair\([^\n]*request\.key/);
|
||||
});
|
||||
|
||||
it("uses the official write methods for ACL, Enterprise, and Audit endpoints", () => {
|
||||
const acl = source("acl.rs");
|
||||
expect(acl).toContain('request_json(Method::PUT, url, Some(&request), false, "clone ACL token")');
|
||||
expect(acl).toContain("(Method::PUT, collection_create_path(kind)?.to_string())");
|
||||
|
||||
const enterprise = source("enterprise.rs");
|
||||
expect(enterprise).toMatch(/None => \(\s*Method::PUT,/);
|
||||
|
||||
const operator = source("operator.rs");
|
||||
expect(operator).toContain('ConsulOperatorReadKind::Audit => "/v1/operator/audit-hash"');
|
||||
expect(operator).toMatch(/matches!\(kind, ConsulOperatorReadKind::Audit\)[\s\S]*?request_json\(\s*Method::POST/);
|
||||
});
|
||||
|
||||
it.each(Object.entries(methodScopeSnapshot))("keeps %s method/scope call counts stable", (file, expected) => {
|
||||
const code = source(file).split("#[cfg(test)]")[0];
|
||||
const count = (pattern: RegExp) => code.match(pattern)?.length ?? 0;
|
||||
expect([
|
||||
count(/Method::GET/g),
|
||||
count(/Method::PUT/g),
|
||||
count(/Method::POST/g),
|
||||
count(/Method::DELETE/g),
|
||||
count(/(?:request_json|send_json)\([^;]*?true/g) + count(/append_scope\(&mut url, true/g) + count(/read_list\(/g),
|
||||
count(/(?:request_json|send_json)\([^;]*?false/g) + count(/append_scope\(&mut url, false/g) + count(/request_json_unscoped\(/g),
|
||||
]).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const tauri = readFileSync(new URL("../tauri.ts", import.meta.url), "utf8");
|
||||
const http = readFileSync(new URL("../http.ts", import.meta.url), "utf8");
|
||||
const api = readFileSync(new URL("../api.ts", import.meta.url), "utf8");
|
||||
const tauriRegistry = readFileSync(new URL("../../../../../../src-tauri/src/lib.rs", import.meta.url), "utf8");
|
||||
const webRegistry = readFileSync(new URL("../../../../../../crates/dbx-web/src/main.rs", import.meta.url), "utf8");
|
||||
const operations = [...api.matchAll(/export const (consul\w+) = forward/g)].map((match) => match[1]);
|
||||
const expectedOperations = [
|
||||
"consulCapabilities",
|
||||
"consulTxn",
|
||||
"consulRenameKey",
|
||||
"consulBlockingQuery",
|
||||
"consulDomainWatch",
|
||||
"consulCancelBlocking",
|
||||
"consulWatchStart",
|
||||
"consulListRecursive",
|
||||
"consulSearch",
|
||||
"consulSearchProgress",
|
||||
"consulCancelSearch",
|
||||
"consulExportBundle",
|
||||
"consulImportPreview",
|
||||
"consulImportExecute",
|
||||
"consulDeletePrefixPreview",
|
||||
"consulDeletePrefixExecute",
|
||||
"consulListPrefix",
|
||||
"consulGet",
|
||||
"consulPut",
|
||||
"consulDelete",
|
||||
"consulPreparedQueryList",
|
||||
"consulPreparedQueryRead",
|
||||
"consulPreparedQueryCreate",
|
||||
"consulPreparedQueryUpdate",
|
||||
"consulPreparedQueryDelete",
|
||||
"consulPreparedQueryExecute",
|
||||
"consulPreparedQueryExplain",
|
||||
"consulEventList",
|
||||
"consulEventFire",
|
||||
"consulCoordinateNodes",
|
||||
"consulOperatorRead",
|
||||
"consulSnapshotGenerate",
|
||||
"consulSnapshotRestore",
|
||||
"consulAutopilotUpdate",
|
||||
"consulRaftTransfer",
|
||||
"consulRaftRemove",
|
||||
"consulKeyringWrite",
|
||||
"consulLicenseWrite",
|
||||
"consulStatusLeader",
|
||||
"consulStatusPeers",
|
||||
"consulAgentSelf",
|
||||
"consulAgentMembers",
|
||||
"consulAgentMetrics",
|
||||
"consulCatalogDatacenters",
|
||||
"consulCatalogNodes",
|
||||
"consulCatalogServices",
|
||||
"consulCatalogServiceNodes",
|
||||
"consulCatalogNodeServices",
|
||||
"consulHealthNode",
|
||||
"consulHealthChecks",
|
||||
"consulHealthService",
|
||||
"consulHealthState",
|
||||
"consulAgentServices",
|
||||
"consulAgentService",
|
||||
"consulAgentChecks",
|
||||
"consulAgentRegisterService",
|
||||
"consulAgentDeregisterService",
|
||||
"consulAgentServiceMaintenance",
|
||||
"consulAgentRegisterCheck",
|
||||
"consulAgentDeregisterCheck",
|
||||
"consulAgentUpdateTtl",
|
||||
"consulSessions",
|
||||
"consulNodeSessions",
|
||||
"consulSession",
|
||||
"consulSessionKeys",
|
||||
"consulSessionDestroyImpact",
|
||||
"consulCreateSession",
|
||||
"consulRenewSession",
|
||||
"consulDestroySession",
|
||||
"consulAcquireLock",
|
||||
"consulReleaseLock",
|
||||
"consulAclList",
|
||||
"consulAclTokenSelf",
|
||||
"consulAclTokenClone",
|
||||
"consulAclGet",
|
||||
"consulAclApply",
|
||||
"consulAclReferences",
|
||||
"consulAclDelete",
|
||||
"consulEnterpriseList",
|
||||
"consulEnterpriseGet",
|
||||
"consulEnterpriseApply",
|
||||
"consulEnterpriseImpact",
|
||||
"consulEnterpriseDelete",
|
||||
"consulMeshConfigList",
|
||||
"consulMeshConfigGet",
|
||||
"consulMeshConfigApply",
|
||||
"consulMeshConfigDelete",
|
||||
"consulMeshIntentionsList",
|
||||
"consulMeshIntentionGet",
|
||||
"consulMeshIntentionGetExact",
|
||||
"consulMeshIntentionUpsert",
|
||||
"consulMeshIntentionDelete",
|
||||
"consulMeshIntentionDeleteExact",
|
||||
"consulMeshIntentionMatch",
|
||||
"consulMeshIntentionCheck",
|
||||
"consulMeshDiscoveryChain",
|
||||
"consulMeshPeeringList",
|
||||
"consulMeshPeeringGet",
|
||||
"consulMeshPeeringGenerateToken",
|
||||
"consulMeshPeeringEstablish",
|
||||
"consulMeshPeeringDelete",
|
||||
"consulMeshExportedServicesList",
|
||||
"consulMeshExportedServicesApply",
|
||||
] as const;
|
||||
|
||||
function functionBody(source: string, operation: string): string {
|
||||
const start = source.indexOf(`export async function ${operation}(`);
|
||||
expect(start, `${operation} transport function`).toBeGreaterThanOrEqual(0);
|
||||
const end = source.indexOf("\nexport async function ", start + 1);
|
||||
return source.slice(start, end === -1 ? source.length : end);
|
||||
}
|
||||
|
||||
function snakeCase(value: string): string {
|
||||
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
describe("Consul dual transport contract", () => {
|
||||
it.each(operations)("exports %s in Tauri and Web transports", (operation) => {
|
||||
expect(tauri).toContain(`function ${operation}`);
|
||||
expect(http).toContain(`function ${operation}`);
|
||||
});
|
||||
|
||||
it("covers every public Consul operation", () => {
|
||||
expect(operations).toEqual(expectedOperations);
|
||||
expect(new Set(operations).size).toBe(operations.length);
|
||||
});
|
||||
|
||||
it.each(expectedOperations)("registers %s end-to-end", (operation) => {
|
||||
const command = functionBody(tauri, operation).match(/invoke\("([^"]+)"/)?.[1];
|
||||
const httpBody = functionBody(http, operation);
|
||||
const route = httpBody.match(/post\("([^"]+)"/)?.[1] ?? (operation === "consulWatchStart" && httpBody.includes("consulBlockingQuery") ? "/api/consul/blocking-query" : undefined);
|
||||
expect(command, `${operation} invoke command`).toBe(snakeCase(operation));
|
||||
expect(route, `${operation} HTTP route`).toMatch(/^\/api\/consul\//);
|
||||
expect(tauriRegistry).toContain(`commands::consul_cmd::${command},`);
|
||||
expect(webRegistry).toContain(`.route("${route?.replace(/^\/api/, "")}", post(`);
|
||||
});
|
||||
|
||||
it("routes cancellable Catalog and Health watches through both transports", () => {
|
||||
expect(tauri).toContain('invoke("consul_domain_watch"');
|
||||
expect(http).toContain('post("/api/consul/domain-watch"');
|
||||
expect(api).toContain('consulDomainWatch = forward("consulDomainWatch")');
|
||||
});
|
||||
|
||||
it("exposes the Agent service detail endpoint in both transports", () => {
|
||||
expect(tauri).toContain('invoke("consul_agent_service"');
|
||||
expect(http).toContain('post("/api/consul/agent/service"');
|
||||
expect(api).toContain('consulAgentService = forward("consulAgentService")');
|
||||
});
|
||||
|
||||
it("keeps advanced domains asynchronously split", () => {
|
||||
const workspace = readFileSync(new URL("../../../components/consul/ConsulWorkspace.vue", import.meta.url), "utf8");
|
||||
for (const component of ["ConsulKeyBrowser", "ConsulServices", "ConsulHealth", "ConsulSessions", "ConsulAcl", "ConsulScope", "ConsulMesh", "ConsulTools", "ConsulOperator"]) {
|
||||
expect(workspace).toContain(`const ${component} = defineAsyncComponent`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -99,4 +99,19 @@ describe("debug log local timestamps", () => {
|
||||
expect(text).toContain(`[${timestamp}] [INFO] hello`);
|
||||
expect(text).not.toContain(".882Z");
|
||||
});
|
||||
|
||||
it("redacts Consul and nested identity-provider secrets", () => {
|
||||
localStorage.setItem(DEBUG_LOG_ENABLED_KEY, "1");
|
||||
appendDebugLog("error", {
|
||||
SecretID: "acl-secret",
|
||||
peering_token: "peering-secret",
|
||||
nested: { OIDCClientSecret: "oidc-secret", safe: "visible" },
|
||||
});
|
||||
|
||||
const text = getDebugLogText();
|
||||
expect(text).not.toContain("acl-secret");
|
||||
expect(text).not.toContain("peering-secret");
|
||||
expect(text).not.toContain("oidc-secret");
|
||||
expect(text).toContain("visible");
|
||||
});
|
||||
});
|
||||
@@ -470,6 +470,111 @@ export const zookeeperGet = forward("zookeeperGet");
|
||||
export const zookeeperPut = forward("zookeeperPut");
|
||||
export const zookeeperDelete = forward("zookeeperDelete");
|
||||
|
||||
// Consul KV
|
||||
export const consulCapabilities = forward("consulCapabilities");
|
||||
export const consulTxn = forward("consulTxn");
|
||||
export const consulRenameKey = forward("consulRenameKey");
|
||||
export const consulBlockingQuery = forward("consulBlockingQuery");
|
||||
export const consulDomainWatch = forward("consulDomainWatch");
|
||||
export const consulCancelBlocking = forward("consulCancelBlocking");
|
||||
export const consulWatchStart = forward("consulWatchStart");
|
||||
export const consulListRecursive = forward("consulListRecursive");
|
||||
export const consulSearch = forward("consulSearch");
|
||||
export const consulSearchProgress = forward("consulSearchProgress");
|
||||
export const consulCancelSearch = forward("consulCancelSearch");
|
||||
export const consulExportBundle = forward("consulExportBundle");
|
||||
export const consulImportPreview = forward("consulImportPreview");
|
||||
export const consulImportExecute = forward("consulImportExecute");
|
||||
export const consulDeletePrefixPreview = forward("consulDeletePrefixPreview");
|
||||
export const consulDeletePrefixExecute = forward("consulDeletePrefixExecute");
|
||||
export const consulListPrefix = forward("consulListPrefix");
|
||||
export const consulGet = forward("consulGet");
|
||||
export const consulPut = forward("consulPut");
|
||||
export const consulDelete = forward("consulDelete");
|
||||
export const consulPreparedQueryList = forward("consulPreparedQueryList");
|
||||
export const consulPreparedQueryRead = forward("consulPreparedQueryRead");
|
||||
export const consulPreparedQueryCreate = forward("consulPreparedQueryCreate");
|
||||
export const consulPreparedQueryUpdate = forward("consulPreparedQueryUpdate");
|
||||
export const consulPreparedQueryDelete = forward("consulPreparedQueryDelete");
|
||||
export const consulPreparedQueryExecute = forward("consulPreparedQueryExecute");
|
||||
export const consulPreparedQueryExplain = forward("consulPreparedQueryExplain");
|
||||
export const consulEventList = forward("consulEventList");
|
||||
export const consulEventFire = forward("consulEventFire");
|
||||
export const consulCoordinateNodes = forward("consulCoordinateNodes");
|
||||
export const consulOperatorRead = forward("consulOperatorRead");
|
||||
export const consulSnapshotGenerate = forward("consulSnapshotGenerate");
|
||||
export const consulSnapshotRestore = forward("consulSnapshotRestore");
|
||||
export const consulAutopilotUpdate = forward("consulAutopilotUpdate");
|
||||
export const consulRaftTransfer = forward("consulRaftTransfer");
|
||||
export const consulRaftRemove = forward("consulRaftRemove");
|
||||
export const consulKeyringWrite = forward("consulKeyringWrite");
|
||||
export const consulLicenseWrite = forward("consulLicenseWrite");
|
||||
export const consulStatusLeader = forward("consulStatusLeader");
|
||||
export const consulStatusPeers = forward("consulStatusPeers");
|
||||
export const consulAgentSelf = forward("consulAgentSelf");
|
||||
export const consulAgentMembers = forward("consulAgentMembers");
|
||||
export const consulAgentMetrics = forward("consulAgentMetrics");
|
||||
export const consulCatalogDatacenters = forward("consulCatalogDatacenters");
|
||||
export const consulCatalogNodes = forward("consulCatalogNodes");
|
||||
export const consulCatalogServices = forward("consulCatalogServices");
|
||||
export const consulCatalogServiceNodes = forward("consulCatalogServiceNodes");
|
||||
export const consulCatalogNodeServices = forward("consulCatalogNodeServices");
|
||||
export const consulHealthNode = forward("consulHealthNode");
|
||||
export const consulHealthChecks = forward("consulHealthChecks");
|
||||
export const consulHealthService = forward("consulHealthService");
|
||||
export const consulHealthState = forward("consulHealthState");
|
||||
export const consulAgentServices = forward("consulAgentServices");
|
||||
export const consulAgentService = forward("consulAgentService");
|
||||
export const consulAgentChecks = forward("consulAgentChecks");
|
||||
export const consulAgentRegisterService = forward("consulAgentRegisterService");
|
||||
export const consulAgentDeregisterService = forward("consulAgentDeregisterService");
|
||||
export const consulAgentServiceMaintenance = forward("consulAgentServiceMaintenance");
|
||||
export const consulAgentRegisterCheck = forward("consulAgentRegisterCheck");
|
||||
export const consulAgentDeregisterCheck = forward("consulAgentDeregisterCheck");
|
||||
export const consulAgentUpdateTtl = forward("consulAgentUpdateTtl");
|
||||
export const consulSessions = forward("consulSessions");
|
||||
export const consulNodeSessions = forward("consulNodeSessions");
|
||||
export const consulSession = forward("consulSession");
|
||||
export const consulSessionKeys = forward("consulSessionKeys");
|
||||
export const consulSessionDestroyImpact = forward("consulSessionDestroyImpact");
|
||||
export const consulCreateSession = forward("consulCreateSession");
|
||||
export const consulRenewSession = forward("consulRenewSession");
|
||||
export const consulDestroySession = forward("consulDestroySession");
|
||||
export const consulAcquireLock = forward("consulAcquireLock");
|
||||
export const consulReleaseLock = forward("consulReleaseLock");
|
||||
export const consulAclList = forward("consulAclList");
|
||||
export const consulAclTokenSelf = forward("consulAclTokenSelf");
|
||||
export const consulAclTokenClone = forward("consulAclTokenClone");
|
||||
export const consulAclGet = forward("consulAclGet");
|
||||
export const consulAclApply = forward("consulAclApply");
|
||||
export const consulAclReferences = forward("consulAclReferences");
|
||||
export const consulAclDelete = forward("consulAclDelete");
|
||||
export const consulEnterpriseList = forward("consulEnterpriseList");
|
||||
export const consulEnterpriseGet = forward("consulEnterpriseGet");
|
||||
export const consulEnterpriseApply = forward("consulEnterpriseApply");
|
||||
export const consulEnterpriseImpact = forward("consulEnterpriseImpact");
|
||||
export const consulEnterpriseDelete = forward("consulEnterpriseDelete");
|
||||
export const consulMeshConfigList = forward("consulMeshConfigList");
|
||||
export const consulMeshConfigGet = forward("consulMeshConfigGet");
|
||||
export const consulMeshConfigApply = forward("consulMeshConfigApply");
|
||||
export const consulMeshConfigDelete = forward("consulMeshConfigDelete");
|
||||
export const consulMeshIntentionsList = forward("consulMeshIntentionsList");
|
||||
export const consulMeshIntentionGet = forward("consulMeshIntentionGet");
|
||||
export const consulMeshIntentionGetExact = forward("consulMeshIntentionGetExact");
|
||||
export const consulMeshIntentionUpsert = forward("consulMeshIntentionUpsert");
|
||||
export const consulMeshIntentionDelete = forward("consulMeshIntentionDelete");
|
||||
export const consulMeshIntentionDeleteExact = forward("consulMeshIntentionDeleteExact");
|
||||
export const consulMeshIntentionMatch = forward("consulMeshIntentionMatch");
|
||||
export const consulMeshIntentionCheck = forward("consulMeshIntentionCheck");
|
||||
export const consulMeshDiscoveryChain = forward("consulMeshDiscoveryChain");
|
||||
export const consulMeshPeeringList = forward("consulMeshPeeringList");
|
||||
export const consulMeshPeeringGet = forward("consulMeshPeeringGet");
|
||||
export const consulMeshPeeringGenerateToken = forward("consulMeshPeeringGenerateToken");
|
||||
export const consulMeshPeeringEstablish = forward("consulMeshPeeringEstablish");
|
||||
export const consulMeshPeeringDelete = forward("consulMeshPeeringDelete");
|
||||
export const consulMeshExportedServicesList = forward("consulMeshExportedServicesList");
|
||||
export const consulMeshExportedServicesApply = forward("consulMeshExportedServicesApply");
|
||||
|
||||
// HBase
|
||||
export const hbaseGetTableSchema = forward("hbaseGetTableSchema");
|
||||
export const hbaseScanRows = forward("hbaseScanRows");
|
||||
|
||||
@@ -53,7 +53,7 @@ function readEntries(): DebugLogEntry[] {
|
||||
}
|
||||
|
||||
function redactSensitiveText(value: string): string {
|
||||
return value.replace(/("(?:password|passphrase|apiKey|api_key|token|secret|connectionString|connection_string)"\s*:\s*")([^"]*)(")/gi, "$1[redacted]$3").replace(/\b(password|passphrase|apiKey|api_key|token|secret)=([^&\s]+)/gi, "$1=[redacted]");
|
||||
return value.replace(/("[^"]*(?:password|passphrase|api[_-]?key|token|secret|jwt|connection[_-]?string)[^"]*"\s*:\s*")([^"]*)(")/gi, "$1[redacted]$3").replace(/\b([^=&\s]*(?:password|passphrase|api[_-]?key|token|secret|jwt)[^=&\s]*)=([^&\s]+)/gi, "$1=[redacted]");
|
||||
}
|
||||
|
||||
function formatValue(value: unknown, seen = new WeakSet<object>()): string {
|
||||
|
||||
@@ -2706,6 +2706,355 @@ export async function zookeeperDelete(connectionId: string, key: string): Promis
|
||||
return post("/api/zookeeper/delete", { connectionId, key });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consul
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function consulCapabilities(connectionId: string): Promise<import("@/types/consul").ConsulCapabilities> {
|
||||
return post("/api/consul/capabilities", { connectionId });
|
||||
}
|
||||
|
||||
export async function consulTxn(connectionId: string, request: import("@/types/consul").ConsulTxnRequest): Promise<import("@/types/consul").ConsulTxnResult> {
|
||||
return post("/api/consul/txn", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulRenameKey(connectionId: string, source: string, target: string, expectedModifyIndex: KvInt64, copy = false): Promise<import("@/types/consul").ConsulTxnResult> {
|
||||
return post("/api/consul/rename-key", { connectionId, source, target, expectedModifyIndex, copy });
|
||||
}
|
||||
|
||||
export async function consulBlockingQuery(connectionId: string, request: import("@/types/consul").ConsulBlockingRequest): Promise<import("@/types/consul").ConsulBlockingResponse> {
|
||||
return post("/api/consul/blocking-query", { connectionId, request });
|
||||
}
|
||||
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogServices" } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<Record<string, string[]>>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogNodes" } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulCatalogNode[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogServiceNodes"; service: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulCatalogServiceNode[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogNodeServices"; node: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulNodeServices>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthNode"; node: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthServiceChecks"; service: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(
|
||||
connectionId: string,
|
||||
request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthServiceInstances"; service: string; passing: boolean | null } },
|
||||
): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulServiceInstance[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthState"; state: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulDomainWatchItems>>;
|
||||
export async function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulDomainWatchItems>> {
|
||||
return post("/api/consul/domain-watch", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulCancelBlocking(connectionId: string, scope: import("@/types/consul").ConsulScope, generation: number, operationId: string): Promise<boolean> {
|
||||
return post("/api/consul/cancel-blocking", { connectionId, scope, generation, operationId });
|
||||
}
|
||||
|
||||
export async function consulWatchStart(connectionId: string, request: import("@/types/consul").ConsulBlockingRequest): Promise<string> {
|
||||
await consulBlockingQuery(connectionId, request);
|
||||
return request.operationId;
|
||||
}
|
||||
|
||||
export async function consulListRecursive(connectionId: string, prefix: string, maxEntries = 10_000, maxValueBytes = 32 * 1024 * 1024): Promise<import("@/types/consul").ConsulRecursiveListResponse> {
|
||||
return post("/api/consul/list-recursive", { connectionId, prefix, maxEntries, maxValueBytes });
|
||||
}
|
||||
|
||||
export async function consulSearch(connectionId: string, request: import("@/types/consul").ConsulSearchRequest): Promise<import("@/types/consul").ConsulSearchResponse> {
|
||||
return post("/api/consul/search", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulSearchProgress(connectionId: string, requestId: string, scope: import("@/types/consul").ConsulScope, generation: number): Promise<import("@/types/consul").ConsulSearchProgress> {
|
||||
return post("/api/consul/search-progress", { connectionId, requestId, scope, generation });
|
||||
}
|
||||
|
||||
export async function consulCancelSearch(connectionId: string, requestId: string, scope: import("@/types/consul").ConsulScope, generation: number): Promise<boolean> {
|
||||
return post("/api/consul/cancel-search", { connectionId, requestId, scope, generation });
|
||||
}
|
||||
|
||||
export async function consulExportBundle(connectionId: string, request: import("@/types/consul").ConsulExportRequest): Promise<import("@/types/consul").ConsulKvBundle> {
|
||||
return post("/api/consul/export-bundle", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulImportPreview(connectionId: string, request: import("@/types/consul").ConsulImportRequest): Promise<import("@/types/consul").ConsulImportPreview> {
|
||||
return post("/api/consul/import-preview", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulImportExecute(connectionId: string, request: import("@/types/consul").ConsulImportRequest): Promise<import("@/types/consul").ConsulImportReport> {
|
||||
return post("/api/consul/import-execute", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulDeletePrefixPreview(connectionId: string, prefix: string): Promise<import("@/types/consul").ConsulDeletePrefixPreview> {
|
||||
return post("/api/consul/delete-prefix-preview", { connectionId, prefix });
|
||||
}
|
||||
|
||||
export async function consulDeletePrefixExecute(connectionId: string, request: import("@/types/consul").ConsulDeletePrefixRequest): Promise<import("@/types/consul").ConsulDeletePrefixReport> {
|
||||
return post("/api/consul/delete-prefix-execute", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null): Promise<KvListPrefixResponse> {
|
||||
return post("/api/consul/list-prefix", { connectionId, prefix, limit, continuation: continuation ?? null });
|
||||
}
|
||||
|
||||
export async function consulGet(connectionId: string, key: string): Promise<KvGetResponse> {
|
||||
return post("/api/consul/get", { connectionId, key });
|
||||
}
|
||||
|
||||
export async function consulPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | null): Promise<KvPutResponse> {
|
||||
return post("/api/consul/put", { connectionId, key, value, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function consulDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise<KvDeleteResponse> {
|
||||
return post("/api/consul/delete", { connectionId, key, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function consulPreparedQueryList(connectionId: string): Promise<import("@/types/consul").ConsulPreparedQuery[]> {
|
||||
return post("/api/consul/prepared-query/list", { connectionId });
|
||||
}
|
||||
export async function consulPreparedQueryRead(connectionId: string, id: string): Promise<import("@/types/consul").ConsulPreparedQuery> {
|
||||
return post("/api/consul/prepared-query/read", { connectionId, id });
|
||||
}
|
||||
export async function consulPreparedQueryCreate(connectionId: string, input: import("@/types/consul").ConsulPreparedQueryInput): Promise<string> {
|
||||
return post("/api/consul/prepared-query/create", { connectionId, input });
|
||||
}
|
||||
export async function consulPreparedQueryUpdate(connectionId: string, id: string, input: import("@/types/consul").ConsulPreparedQueryInput): Promise<void> {
|
||||
await post("/api/consul/prepared-query/update", { connectionId, id, input });
|
||||
}
|
||||
export async function consulPreparedQueryDelete(connectionId: string, id: string): Promise<void> {
|
||||
await post("/api/consul/prepared-query/delete", { connectionId, id });
|
||||
}
|
||||
export async function consulPreparedQueryExecute(connectionId: string, request: import("@/types/consul").ConsulPreparedQueryExecuteRequest): Promise<import("@/types/consul").ConsulPreparedQueryExecuteResponse> {
|
||||
return post("/api/consul/prepared-query/execute", { connectionId, request });
|
||||
}
|
||||
export async function consulPreparedQueryExplain(connectionId: string, query: string): Promise<unknown> {
|
||||
return post("/api/consul/prepared-query/explain", { connectionId, id: query });
|
||||
}
|
||||
export async function consulEventList(connectionId: string, name?: string | null): Promise<import("@/types/consul").ConsulEvent[]> {
|
||||
return post("/api/consul/event/list", { connectionId, name: name ?? null });
|
||||
}
|
||||
export async function consulEventFire(connectionId: string, request: import("@/types/consul").ConsulEventFireRequest): Promise<import("@/types/consul").ConsulEvent> {
|
||||
return post("/api/consul/event/fire", { connectionId, request });
|
||||
}
|
||||
export async function consulCoordinateNodes(connectionId: string): Promise<import("@/types/consul").ConsulCoordinate[]> {
|
||||
return post("/api/consul/coordinate/nodes", { connectionId });
|
||||
}
|
||||
export async function consulOperatorRead(connectionId: string, kind: import("@/types/consul").ConsulOperatorReadKind): Promise<import("@/types/consul").ConsulOperatorDocument> {
|
||||
return post("/api/consul/operator/read", { connectionId, kind });
|
||||
}
|
||||
export async function consulSnapshotGenerate(connectionId: string): Promise<import("@/types/consul").ConsulSnapshot> {
|
||||
return post("/api/consul/operator/snapshot/generate", { connectionId });
|
||||
}
|
||||
export async function consulSnapshotRestore(connectionId: string, request: import("@/types/consul").ConsulSnapshotRestoreRequest): Promise<void> {
|
||||
await post("/api/consul/operator/snapshot/restore", { connectionId, request });
|
||||
}
|
||||
export async function consulAutopilotUpdate(connectionId: string, update: import("@/types/consul").ConsulAutopilotUpdate, confirmation: string): Promise<void> {
|
||||
await post("/api/consul/operator/autopilot/update", { connectionId, update, confirmation });
|
||||
}
|
||||
export async function consulRaftTransfer(connectionId: string, request: import("@/types/consul").ConsulRaftWriteRequest): Promise<void> {
|
||||
await post("/api/consul/operator/raft/transfer", { connectionId, request });
|
||||
}
|
||||
export async function consulRaftRemove(connectionId: string, request: import("@/types/consul").ConsulRaftWriteRequest): Promise<void> {
|
||||
await post("/api/consul/operator/raft/remove", { connectionId, request });
|
||||
}
|
||||
export async function consulKeyringWrite(connectionId: string, request: import("@/types/consul").ConsulKeyringWriteRequest): Promise<void> {
|
||||
await post("/api/consul/operator/keyring/write", { connectionId, request });
|
||||
}
|
||||
export async function consulLicenseWrite(connectionId: string, request: import("@/types/consul").ConsulLicenseWriteRequest): Promise<void> {
|
||||
await post("/api/consul/operator/license/write", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulStatusLeader(connectionId: string): Promise<string> {
|
||||
return post("/api/consul/status/leader", { connectionId });
|
||||
}
|
||||
export async function consulStatusPeers(connectionId: string): Promise<string[]> {
|
||||
return post("/api/consul/status/peers", { connectionId });
|
||||
}
|
||||
export async function consulAgentSelf(connectionId: string): Promise<import("@/types/consul").ConsulAgentIdentity> {
|
||||
return post("/api/consul/agent/self", { connectionId });
|
||||
}
|
||||
export async function consulAgentMembers(connectionId: string, wan = false, segment?: string | null): Promise<import("@/types/consul").ConsulAgentMember[]> {
|
||||
return post("/api/consul/agent/members", { connectionId, wan, segment: segment ?? null });
|
||||
}
|
||||
export async function consulAgentMetrics(connectionId: string): Promise<unknown> {
|
||||
return post("/api/consul/agent/metrics", { connectionId });
|
||||
}
|
||||
export async function consulCatalogDatacenters(connectionId: string): Promise<string[]> {
|
||||
return post("/api/consul/catalog/datacenters", { connectionId });
|
||||
}
|
||||
export async function consulCatalogNodes(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulCatalogNode[]>> {
|
||||
return post("/api/consul/catalog/nodes", { connectionId, options });
|
||||
}
|
||||
export async function consulCatalogServices(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<Record<string, string[]>>> {
|
||||
return post("/api/consul/catalog/services", { connectionId, options });
|
||||
}
|
||||
export async function consulCatalogServiceNodes(connectionId: string, service: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulCatalogServiceNode[]>> {
|
||||
return post("/api/consul/catalog/service-nodes", { connectionId, name: service, options });
|
||||
}
|
||||
export async function consulCatalogNodeServices(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulNodeServices>> {
|
||||
return post("/api/consul/catalog/node-services", { connectionId, name: node, options });
|
||||
}
|
||||
export async function consulHealthNode(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return post("/api/consul/health/node", { connectionId, name: node, options });
|
||||
}
|
||||
export async function consulHealthChecks(connectionId: string, service: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return post("/api/consul/health/checks", { connectionId, name: service, options });
|
||||
}
|
||||
export async function consulHealthService(connectionId: string, service: string, passing: boolean | null = null, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulServiceInstance[]>> {
|
||||
return post("/api/consul/health/service", { connectionId, name: service, passing, options });
|
||||
}
|
||||
export async function consulHealthState(connectionId: string, healthState: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return post("/api/consul/health/state", { connectionId, name: healthState, options });
|
||||
}
|
||||
export async function consulAgentServices(connectionId: string): Promise<Record<string, import("@/types/consul").ConsulAgentService>> {
|
||||
return post("/api/consul/agent/services", { connectionId });
|
||||
}
|
||||
export async function consulAgentService(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentService> {
|
||||
return post("/api/consul/agent/service", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentChecks(connectionId: string): Promise<Record<string, import("@/types/consul").ConsulHealthCheck>> {
|
||||
return post("/api/consul/agent/checks", { connectionId });
|
||||
}
|
||||
export async function consulAgentRegisterService(connectionId: string, registration: import("@/types/consul").ConsulAgentServiceRegistration): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/service/register", { connectionId, registration });
|
||||
}
|
||||
export async function consulAgentDeregisterService(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/service/deregister", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentServiceMaintenance(connectionId: string, id: string, enable: boolean, reason?: string | null): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/service/maintenance", { connectionId, id, enable, reason: reason ?? null });
|
||||
}
|
||||
export async function consulAgentRegisterCheck(connectionId: string, registration: import("@/types/consul").ConsulAgentCheckRegistration): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/check/register", { connectionId, registration });
|
||||
}
|
||||
export async function consulAgentDeregisterCheck(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/check/deregister", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentUpdateTtl(connectionId: string, id: string, status: import("@/types/consul").ConsulCheckStatus, output?: string | null): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return post("/api/consul/agent/check/ttl", { connectionId, id, status, output: output ?? null });
|
||||
}
|
||||
export async function consulSessions(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulSession[]>> {
|
||||
return post("/api/consul/sessions", { connectionId, options });
|
||||
}
|
||||
export async function consulNodeSessions(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulSession[]>> {
|
||||
return post("/api/consul/sessions/node", { connectionId, name: node, options });
|
||||
}
|
||||
export async function consulSession(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSession | null> {
|
||||
return post("/api/consul/session", { connectionId, id });
|
||||
}
|
||||
export async function consulSessionKeys(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSessionKeysResponse> {
|
||||
return post("/api/consul/session/keys", { connectionId, id });
|
||||
}
|
||||
export async function consulSessionDestroyImpact(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSessionDestroyImpact> {
|
||||
return post("/api/consul/session/destroy-impact", { connectionId, id });
|
||||
}
|
||||
export async function consulCreateSession(connectionId: string, request: import("@/types/consul").ConsulSessionCreateRequest): Promise<import("@/types/consul").ConsulSession> {
|
||||
return post("/api/consul/session/create", { connectionId, request });
|
||||
}
|
||||
export async function consulRenewSession(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSession> {
|
||||
return post("/api/consul/session/renew", { connectionId, id });
|
||||
}
|
||||
export async function consulDestroySession(connectionId: string, request: import("@/types/consul").ConsulSessionDestroyRequest): Promise<boolean> {
|
||||
return post("/api/consul/session/destroy", { connectionId, request });
|
||||
}
|
||||
export async function consulAcquireLock(connectionId: string, request: import("@/types/consul").ConsulLockRequest): Promise<import("@/types/consul").ConsulLockResponse> {
|
||||
return post("/api/consul/lock/acquire", { connectionId, request });
|
||||
}
|
||||
export async function consulReleaseLock(connectionId: string, key: string, session: string): Promise<import("@/types/consul").ConsulLockResponse> {
|
||||
return post("/api/consul/lock/release", { connectionId, key, session });
|
||||
}
|
||||
|
||||
export async function consulAclList(connectionId: string, kind: import("@/types/consul").ConsulAclKind): Promise<import("@/types/consul").ConsulAclList> {
|
||||
return post("/api/consul/acl/list", { connectionId, kind });
|
||||
}
|
||||
export async function consulAclTokenSelf(connectionId: string): Promise<import("@/types/consul").ConsulAclToken> {
|
||||
return post("/api/consul/acl/token/self", { connectionId });
|
||||
}
|
||||
export async function consulAclTokenClone(connectionId: string, accessorId: string, description: string): Promise<import("@/types/consul").ConsulAclToken> {
|
||||
return post("/api/consul/acl/token/clone", { connectionId, id: accessorId, description });
|
||||
}
|
||||
export async function consulAclGet(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclItem> {
|
||||
return post("/api/consul/acl/get", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulAclApply(connectionId: string, id: string | null, value: import("@/types/consul").ConsulAclWrite): Promise<import("@/types/consul").ConsulAclItem> {
|
||||
return post("/api/consul/acl/apply", { connectionId, id, value });
|
||||
}
|
||||
export async function consulAclReferences(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclReferences> {
|
||||
return post("/api/consul/acl/references", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulAclDelete(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclReferences> {
|
||||
return post("/api/consul/acl/delete", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulEnterpriseList(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind): Promise<import("@/types/consul").ConsulEnterpriseList> {
|
||||
return post("/api/consul/enterprise/list", { connectionId, kind });
|
||||
}
|
||||
export async function consulEnterpriseGet(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulEnterpriseItem> {
|
||||
return post("/api/consul/enterprise/get", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulEnterpriseApply(connectionId: string, existingName: string | null, item: import("@/types/consul").ConsulEnterpriseWrite): Promise<import("@/types/consul").ConsulEnterpriseItem> {
|
||||
return post("/api/consul/enterprise/apply", { connectionId, existingName, item });
|
||||
}
|
||||
export async function consulEnterpriseImpact(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulScopeImpact> {
|
||||
return post("/api/consul/enterprise/impact", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulEnterpriseDelete(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulScopeImpact> {
|
||||
return post("/api/consul/enterprise/delete", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulMeshConfigList(connectionId: string, kind: string): Promise<import("@/types/consul").ConsulConfigEntry[]> {
|
||||
return post("/api/consul/mesh/config/list", { connectionId, kind });
|
||||
}
|
||||
export async function consulMeshConfigGet(connectionId: string, kind: string, name: string): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return post("/api/consul/mesh/config/get", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulMeshConfigApply(connectionId: string, request: import("@/types/consul").ConsulConfigEntryApply): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return post("/api/consul/mesh/config/apply", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshConfigDelete(connectionId: string, kind: string, name: string, expectedModifyIndex: number): Promise<boolean> {
|
||||
return post("/api/consul/mesh/config/delete", { connectionId, kind, name, expectedModifyIndex });
|
||||
}
|
||||
export async function consulMeshIntentionsList(connectionId: string): Promise<import("@/types/consul").ConsulIntention[]> {
|
||||
return post("/api/consul/mesh/intentions/list", { connectionId });
|
||||
}
|
||||
export async function consulMeshIntentionGet(connectionId: string, id: string): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return post("/api/consul/mesh/intentions/get", { connectionId, id });
|
||||
}
|
||||
export async function consulMeshIntentionGetExact(connectionId: string, request: import("@/types/consul").ConsulIntentionExactRequest): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return post("/api/consul/mesh/intentions/get-exact", { connectionId, exactRequest: request });
|
||||
}
|
||||
export async function consulMeshIntentionUpsert(connectionId: string, item: import("@/types/consul").ConsulIntention): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return post("/api/consul/mesh/intentions/upsert", { connectionId, intention: item });
|
||||
}
|
||||
export async function consulMeshIntentionDelete(connectionId: string, id: string): Promise<boolean> {
|
||||
return post("/api/consul/mesh/intentions/delete", { connectionId, id });
|
||||
}
|
||||
export async function consulMeshIntentionDeleteExact(connectionId: string, request: import("@/types/consul").ConsulIntentionExactRequest): Promise<boolean> {
|
||||
return post("/api/consul/mesh/intentions/delete-exact", { connectionId, exactRequest: request });
|
||||
}
|
||||
export async function consulMeshIntentionMatch(connectionId: string, request: import("@/types/consul").ConsulIntentionMatchRequest): Promise<import("@/types/consul").ConsulIntention[]> {
|
||||
return post("/api/consul/mesh/intentions/match", { connectionId, matchRequest: request });
|
||||
}
|
||||
export async function consulMeshIntentionCheck(connectionId: string, request: import("@/types/consul").ConsulIntentionCheckRequest): Promise<import("@/types/consul").ConsulIntentionCheckResponse> {
|
||||
return post("/api/consul/mesh/intentions/check", { connectionId, checkRequest: request });
|
||||
}
|
||||
export async function consulMeshDiscoveryChain(connectionId: string, service: string): Promise<import("@/types/consul").ConsulDiscoveryChain> {
|
||||
return post("/api/consul/mesh/discovery-chain", { connectionId, service });
|
||||
}
|
||||
export async function consulMeshPeeringList(connectionId: string): Promise<import("@/types/consul").ConsulPeering[]> {
|
||||
return post("/api/consul/mesh/peerings/list", { connectionId });
|
||||
}
|
||||
export async function consulMeshPeeringGet(connectionId: string, name: string): Promise<import("@/types/consul").ConsulPeering> {
|
||||
return post("/api/consul/mesh/peerings/get", { connectionId, name });
|
||||
}
|
||||
export async function consulMeshPeeringGenerateToken(connectionId: string, request: import("@/types/consul").ConsulPeeringGenerateRequest): Promise<import("@/types/consul").ConsulPeeringToken> {
|
||||
return post("/api/consul/mesh/peerings/generate-token", { connectionId, generateRequest: request });
|
||||
}
|
||||
export async function consulMeshPeeringEstablish(connectionId: string, request: import("@/types/consul").ConsulPeeringEstablishRequest): Promise<import("@/types/consul").ConsulPeering> {
|
||||
return post("/api/consul/mesh/peerings/establish", { connectionId, establishRequest: request });
|
||||
}
|
||||
export async function consulMeshPeeringDelete(connectionId: string, name: string): Promise<boolean> {
|
||||
return post("/api/consul/mesh/peerings/delete", { connectionId, name });
|
||||
}
|
||||
export async function consulMeshExportedServicesList(connectionId: string): Promise<import("@/types/consul").ConsulExportedService[]> {
|
||||
return post("/api/consul/mesh/exported-services/list", { connectionId });
|
||||
}
|
||||
export async function consulMeshExportedServicesApply(connectionId: string, name: string, expectedModifyIndex: number, raw: Record<string, unknown>): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return post("/api/consul/mesh/exported-services/apply", { connectionId, name, expectedModifyIndex, raw });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nacos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2404,6 +2404,9 @@ export interface KvKeyMetadata {
|
||||
ephemeralOwner?: number | null;
|
||||
dataLength?: number | null;
|
||||
numChildren?: number | null;
|
||||
flags?: KvInt64 | number | null;
|
||||
lockIndex?: KvInt64 | number | null;
|
||||
session?: string | null;
|
||||
}
|
||||
|
||||
export interface KvKeySummary extends KvKeyMetadata {
|
||||
@@ -2417,6 +2420,7 @@ export interface KvListPrefixResponse {
|
||||
keys: KvKeySummary[];
|
||||
continuation?: string | null;
|
||||
revision?: KvInt64 | number | null;
|
||||
filteredByAcls?: boolean | null;
|
||||
}
|
||||
|
||||
export interface KvListPrefixOptions {
|
||||
@@ -2460,6 +2464,7 @@ export interface KvPutOptions {
|
||||
keyBytes?: KvValue | null;
|
||||
expectedModRevision?: KvInt64 | null;
|
||||
expectedCreateRevision?: KvInt64 | null;
|
||||
flags?: KvInt64 | null;
|
||||
}
|
||||
|
||||
export interface KvDeleteOptions {
|
||||
@@ -2804,6 +2809,351 @@ export async function zookeeperDelete(connectionId: string, key: string): Promis
|
||||
return invoke("zookeeper_delete", { connectionId, key });
|
||||
}
|
||||
|
||||
// --- Consul KV ---
|
||||
export async function consulCapabilities(connectionId: string): Promise<import("@/types/consul").ConsulCapabilities> {
|
||||
return invoke("consul_capabilities", { connectionId });
|
||||
}
|
||||
|
||||
export async function consulTxn(connectionId: string, request: import("@/types/consul").ConsulTxnRequest): Promise<import("@/types/consul").ConsulTxnResult> {
|
||||
return invoke("consul_txn", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulRenameKey(connectionId: string, source: string, target: string, expectedModifyIndex: KvInt64, copy = false): Promise<import("@/types/consul").ConsulTxnResult> {
|
||||
return invoke("consul_rename_key", { connectionId, source, target, expectedModifyIndex, copy });
|
||||
}
|
||||
|
||||
export async function consulBlockingQuery(connectionId: string, request: import("@/types/consul").ConsulBlockingRequest): Promise<import("@/types/consul").ConsulBlockingResponse> {
|
||||
return invoke("consul_blocking_query", { connectionId, request });
|
||||
}
|
||||
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogServices" } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<Record<string, string[]>>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogNodes" } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulCatalogNode[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogServiceNodes"; service: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulCatalogServiceNode[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "catalogNodeServices"; node: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulNodeServices>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthNode"; node: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthServiceChecks"; service: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(
|
||||
connectionId: string,
|
||||
request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthServiceInstances"; service: string; passing: boolean | null } },
|
||||
): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulServiceInstance[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest & { target: { kind: "healthState"; state: string } }): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulHealthCheck[]>>;
|
||||
export function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulDomainWatchItems>>;
|
||||
export async function consulDomainWatch(connectionId: string, request: import("@/types/consul").ConsulDomainWatchRequest): Promise<import("@/types/consul").ConsulDomainWatchResponse<import("@/types/consul").ConsulDomainWatchItems>> {
|
||||
return invoke("consul_domain_watch", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulCancelBlocking(connectionId: string, scope: import("@/types/consul").ConsulScope, generation: number, operationId: string): Promise<boolean> {
|
||||
return invoke("consul_cancel_blocking", { connectionId, scope, generation, operationId });
|
||||
}
|
||||
|
||||
export async function consulWatchStart(connectionId: string, request: import("@/types/consul").ConsulBlockingRequest): Promise<string> {
|
||||
return invoke("consul_watch_start", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulListRecursive(connectionId: string, prefix: string, maxEntries = 10_000, maxValueBytes = 32 * 1024 * 1024): Promise<import("@/types/consul").ConsulRecursiveListResponse> {
|
||||
return invoke("consul_list_recursive", { connectionId, prefix, maxEntries, maxValueBytes });
|
||||
}
|
||||
|
||||
export async function consulSearch(connectionId: string, request: import("@/types/consul").ConsulSearchRequest): Promise<import("@/types/consul").ConsulSearchResponse> {
|
||||
return invoke("consul_search", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulSearchProgress(connectionId: string, requestId: string, scope: import("@/types/consul").ConsulScope, generation: number): Promise<import("@/types/consul").ConsulSearchProgress> {
|
||||
return invoke("consul_search_progress", { connectionId, requestId, scope, generation });
|
||||
}
|
||||
|
||||
export async function consulCancelSearch(connectionId: string, requestId: string, scope: import("@/types/consul").ConsulScope, generation: number): Promise<boolean> {
|
||||
return invoke("consul_cancel_search", { connectionId, requestId, scope, generation });
|
||||
}
|
||||
|
||||
export async function consulExportBundle(connectionId: string, request: import("@/types/consul").ConsulExportRequest): Promise<import("@/types/consul").ConsulKvBundle> {
|
||||
return invoke("consul_export_bundle", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulImportPreview(connectionId: string, request: import("@/types/consul").ConsulImportRequest): Promise<import("@/types/consul").ConsulImportPreview> {
|
||||
return invoke("consul_import_preview", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulImportExecute(connectionId: string, request: import("@/types/consul").ConsulImportRequest): Promise<import("@/types/consul").ConsulImportReport> {
|
||||
return invoke("consul_import_execute", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulDeletePrefixPreview(connectionId: string, prefix: string): Promise<import("@/types/consul").ConsulDeletePrefixPreview> {
|
||||
return invoke("consul_delete_prefix_preview", { connectionId, prefix });
|
||||
}
|
||||
|
||||
export async function consulDeletePrefixExecute(connectionId: string, request: import("@/types/consul").ConsulDeletePrefixRequest): Promise<import("@/types/consul").ConsulDeletePrefixReport> {
|
||||
return invoke("consul_delete_prefix_execute", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null): Promise<KvListPrefixResponse> {
|
||||
return invoke("consul_list_prefix", { connectionId, prefix, limit, continuation: continuation ?? null });
|
||||
}
|
||||
|
||||
export async function consulGet(connectionId: string, key: string): Promise<KvGetResponse> {
|
||||
return invoke("consul_get", { connectionId, key });
|
||||
}
|
||||
|
||||
export async function consulPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | null): Promise<KvPutResponse> {
|
||||
return invoke("consul_put", { connectionId, key, value, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function consulDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise<KvDeleteResponse> {
|
||||
return invoke("consul_delete", { connectionId, key, options: options ?? null });
|
||||
}
|
||||
|
||||
export async function consulPreparedQueryList(connectionId: string): Promise<import("@/types/consul").ConsulPreparedQuery[]> {
|
||||
return invoke("consul_prepared_query_list", { connectionId });
|
||||
}
|
||||
export async function consulPreparedQueryRead(connectionId: string, id: string): Promise<import("@/types/consul").ConsulPreparedQuery> {
|
||||
return invoke("consul_prepared_query_read", { connectionId, id });
|
||||
}
|
||||
export async function consulPreparedQueryCreate(connectionId: string, input: import("@/types/consul").ConsulPreparedQueryInput): Promise<string> {
|
||||
return invoke("consul_prepared_query_create", { connectionId, input });
|
||||
}
|
||||
export async function consulPreparedQueryUpdate(connectionId: string, id: string, input: import("@/types/consul").ConsulPreparedQueryInput): Promise<void> {
|
||||
return invoke("consul_prepared_query_update", { connectionId, id, input });
|
||||
}
|
||||
export async function consulPreparedQueryDelete(connectionId: string, id: string): Promise<void> {
|
||||
return invoke("consul_prepared_query_delete", { connectionId, id });
|
||||
}
|
||||
export async function consulPreparedQueryExecute(connectionId: string, request: import("@/types/consul").ConsulPreparedQueryExecuteRequest): Promise<import("@/types/consul").ConsulPreparedQueryExecuteResponse> {
|
||||
return invoke("consul_prepared_query_execute", { connectionId, request });
|
||||
}
|
||||
export async function consulPreparedQueryExplain(connectionId: string, query: string): Promise<unknown> {
|
||||
return invoke("consul_prepared_query_explain", { connectionId, query });
|
||||
}
|
||||
export async function consulEventList(connectionId: string, name?: string | null): Promise<import("@/types/consul").ConsulEvent[]> {
|
||||
return invoke("consul_event_list", { connectionId, name: name ?? null });
|
||||
}
|
||||
export async function consulEventFire(connectionId: string, request: import("@/types/consul").ConsulEventFireRequest): Promise<import("@/types/consul").ConsulEvent> {
|
||||
return invoke("consul_event_fire", { connectionId, request });
|
||||
}
|
||||
export async function consulCoordinateNodes(connectionId: string): Promise<import("@/types/consul").ConsulCoordinate[]> {
|
||||
return invoke("consul_coordinate_nodes", { connectionId });
|
||||
}
|
||||
export async function consulOperatorRead(connectionId: string, kind: import("@/types/consul").ConsulOperatorReadKind): Promise<import("@/types/consul").ConsulOperatorDocument> {
|
||||
return invoke("consul_operator_read", { connectionId, kind });
|
||||
}
|
||||
export async function consulSnapshotGenerate(connectionId: string): Promise<import("@/types/consul").ConsulSnapshot> {
|
||||
return invoke("consul_snapshot_generate", { connectionId });
|
||||
}
|
||||
export async function consulSnapshotRestore(connectionId: string, request: import("@/types/consul").ConsulSnapshotRestoreRequest): Promise<void> {
|
||||
return invoke("consul_snapshot_restore", { connectionId, request });
|
||||
}
|
||||
export async function consulAutopilotUpdate(connectionId: string, update: import("@/types/consul").ConsulAutopilotUpdate, confirmation: string): Promise<void> {
|
||||
return invoke("consul_autopilot_update", { connectionId, update, confirmation });
|
||||
}
|
||||
export async function consulRaftTransfer(connectionId: string, request: import("@/types/consul").ConsulRaftWriteRequest): Promise<void> {
|
||||
return invoke("consul_raft_transfer", { connectionId, request });
|
||||
}
|
||||
export async function consulRaftRemove(connectionId: string, request: import("@/types/consul").ConsulRaftWriteRequest): Promise<void> {
|
||||
return invoke("consul_raft_remove", { connectionId, request });
|
||||
}
|
||||
export async function consulKeyringWrite(connectionId: string, request: import("@/types/consul").ConsulKeyringWriteRequest): Promise<void> {
|
||||
return invoke("consul_keyring_write", { connectionId, request });
|
||||
}
|
||||
export async function consulLicenseWrite(connectionId: string, request: import("@/types/consul").ConsulLicenseWriteRequest): Promise<void> {
|
||||
return invoke("consul_license_write", { connectionId, request });
|
||||
}
|
||||
|
||||
export async function consulStatusLeader(connectionId: string): Promise<string> {
|
||||
return invoke("consul_status_leader", { connectionId });
|
||||
}
|
||||
export async function consulStatusPeers(connectionId: string): Promise<string[]> {
|
||||
return invoke("consul_status_peers", { connectionId });
|
||||
}
|
||||
export async function consulAgentSelf(connectionId: string): Promise<import("@/types/consul").ConsulAgentIdentity> {
|
||||
return invoke("consul_agent_self", { connectionId });
|
||||
}
|
||||
export async function consulAgentMembers(connectionId: string, wan = false, segment?: string | null): Promise<import("@/types/consul").ConsulAgentMember[]> {
|
||||
return invoke("consul_agent_members", { connectionId, wan, segment: segment ?? null });
|
||||
}
|
||||
export async function consulAgentMetrics(connectionId: string): Promise<unknown> {
|
||||
return invoke("consul_agent_metrics", { connectionId });
|
||||
}
|
||||
export async function consulCatalogDatacenters(connectionId: string): Promise<string[]> {
|
||||
return invoke("consul_catalog_datacenters", { connectionId });
|
||||
}
|
||||
export async function consulCatalogNodes(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulCatalogNode[]>> {
|
||||
return invoke("consul_catalog_nodes", { connectionId, options });
|
||||
}
|
||||
export async function consulCatalogServices(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<Record<string, string[]>>> {
|
||||
return invoke("consul_catalog_services", { connectionId, options });
|
||||
}
|
||||
export async function consulCatalogServiceNodes(connectionId: string, service: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulCatalogServiceNode[]>> {
|
||||
return invoke("consul_catalog_service_nodes", { connectionId, service, options });
|
||||
}
|
||||
export async function consulCatalogNodeServices(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulNodeServices>> {
|
||||
return invoke("consul_catalog_node_services", { connectionId, node, options });
|
||||
}
|
||||
export async function consulHealthNode(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return invoke("consul_health_node", { connectionId, node, options });
|
||||
}
|
||||
export async function consulHealthChecks(connectionId: string, service: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return invoke("consul_health_checks", { connectionId, service, options });
|
||||
}
|
||||
export async function consulHealthService(connectionId: string, service: string, passing: boolean | null = null, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulServiceInstance[]>> {
|
||||
return invoke("consul_health_service", { connectionId, service, passing, options });
|
||||
}
|
||||
export async function consulHealthState(connectionId: string, healthState: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulHealthCheck[]>> {
|
||||
return invoke("consul_health_state", { connectionId, healthState, options });
|
||||
}
|
||||
export async function consulAgentServices(connectionId: string): Promise<Record<string, import("@/types/consul").ConsulAgentService>> {
|
||||
return invoke("consul_agent_services", { connectionId });
|
||||
}
|
||||
export async function consulAgentService(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentService> {
|
||||
return invoke("consul_agent_service", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentChecks(connectionId: string): Promise<Record<string, import("@/types/consul").ConsulHealthCheck>> {
|
||||
return invoke("consul_agent_checks", { connectionId });
|
||||
}
|
||||
export async function consulAgentRegisterService(connectionId: string, registration: import("@/types/consul").ConsulAgentServiceRegistration): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_register_service", { connectionId, registration });
|
||||
}
|
||||
export async function consulAgentDeregisterService(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_deregister_service", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentServiceMaintenance(connectionId: string, id: string, enable: boolean, reason?: string | null): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_service_maintenance", { connectionId, id, enable, reason: reason ?? null });
|
||||
}
|
||||
export async function consulAgentRegisterCheck(connectionId: string, registration: import("@/types/consul").ConsulAgentCheckRegistration): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_register_check", { connectionId, registration });
|
||||
}
|
||||
export async function consulAgentDeregisterCheck(connectionId: string, id: string): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_deregister_check", { connectionId, id });
|
||||
}
|
||||
export async function consulAgentUpdateTtl(connectionId: string, id: string, status: import("@/types/consul").ConsulCheckStatus, output?: string | null): Promise<import("@/types/consul").ConsulAgentWriteResult> {
|
||||
return invoke("consul_agent_update_ttl", { connectionId, id, status, output: output ?? null });
|
||||
}
|
||||
export async function consulSessions(connectionId: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulSession[]>> {
|
||||
return invoke("consul_sessions", { connectionId, options });
|
||||
}
|
||||
export async function consulNodeSessions(connectionId: string, node: string, options: import("@/types/consul").ConsulReadOptions = {}): Promise<import("@/types/consul").ConsulListResponse<import("@/types/consul").ConsulSession[]>> {
|
||||
return invoke("consul_node_sessions", { connectionId, node, options });
|
||||
}
|
||||
export async function consulSession(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSession | null> {
|
||||
return invoke("consul_session", { connectionId, id });
|
||||
}
|
||||
export async function consulSessionKeys(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSessionKeysResponse> {
|
||||
return invoke("consul_session_keys", { connectionId, id });
|
||||
}
|
||||
export async function consulSessionDestroyImpact(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSessionDestroyImpact> {
|
||||
return invoke("consul_session_destroy_impact", { connectionId, id });
|
||||
}
|
||||
export async function consulCreateSession(connectionId: string, request: import("@/types/consul").ConsulSessionCreateRequest): Promise<import("@/types/consul").ConsulSession> {
|
||||
return invoke("consul_create_session", { connectionId, request });
|
||||
}
|
||||
export async function consulRenewSession(connectionId: string, id: string): Promise<import("@/types/consul").ConsulSession> {
|
||||
return invoke("consul_renew_session", { connectionId, id });
|
||||
}
|
||||
export async function consulDestroySession(connectionId: string, request: import("@/types/consul").ConsulSessionDestroyRequest): Promise<boolean> {
|
||||
return invoke("consul_destroy_session", { connectionId, request });
|
||||
}
|
||||
export async function consulAcquireLock(connectionId: string, request: import("@/types/consul").ConsulLockRequest): Promise<import("@/types/consul").ConsulLockResponse> {
|
||||
return invoke("consul_acquire_lock", { connectionId, request });
|
||||
}
|
||||
export async function consulReleaseLock(connectionId: string, key: string, session: string): Promise<import("@/types/consul").ConsulLockResponse> {
|
||||
return invoke("consul_release_lock", { connectionId, key, session });
|
||||
}
|
||||
|
||||
export async function consulAclList(connectionId: string, kind: import("@/types/consul").ConsulAclKind): Promise<import("@/types/consul").ConsulAclList> {
|
||||
return invoke("consul_acl_list", { connectionId, kind });
|
||||
}
|
||||
export async function consulAclTokenSelf(connectionId: string): Promise<import("@/types/consul").ConsulAclToken> {
|
||||
return invoke("consul_acl_token_self", { connectionId });
|
||||
}
|
||||
export async function consulAclTokenClone(connectionId: string, accessorId: string, description: string): Promise<import("@/types/consul").ConsulAclToken> {
|
||||
return invoke("consul_acl_token_clone", { connectionId, accessorId, request: { Description: description } });
|
||||
}
|
||||
export async function consulAclGet(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclItem> {
|
||||
return invoke("consul_acl_get", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulAclApply(connectionId: string, id: string | null, value: import("@/types/consul").ConsulAclWrite): Promise<import("@/types/consul").ConsulAclItem> {
|
||||
return invoke("consul_acl_apply", { connectionId, id, value });
|
||||
}
|
||||
export async function consulAclReferences(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclReferences> {
|
||||
return invoke("consul_acl_references", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulAclDelete(connectionId: string, kind: import("@/types/consul").ConsulAclKind, id: string): Promise<import("@/types/consul").ConsulAclReferences> {
|
||||
return invoke("consul_acl_delete", { connectionId, kind, id });
|
||||
}
|
||||
export async function consulEnterpriseList(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind): Promise<import("@/types/consul").ConsulEnterpriseList> {
|
||||
return invoke("consul_enterprise_list", { connectionId, kind });
|
||||
}
|
||||
export async function consulEnterpriseGet(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulEnterpriseItem> {
|
||||
return invoke("consul_enterprise_get", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulEnterpriseApply(connectionId: string, existingName: string | null, item: import("@/types/consul").ConsulEnterpriseWrite): Promise<import("@/types/consul").ConsulEnterpriseItem> {
|
||||
return invoke("consul_enterprise_apply", { connectionId, existingName, item });
|
||||
}
|
||||
export async function consulEnterpriseImpact(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulScopeImpact> {
|
||||
return invoke("consul_enterprise_impact", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulEnterpriseDelete(connectionId: string, kind: import("@/types/consul").ConsulEnterpriseKind, name: string): Promise<import("@/types/consul").ConsulScopeImpact> {
|
||||
return invoke("consul_enterprise_delete", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulMeshConfigList(connectionId: string, kind: string): Promise<import("@/types/consul").ConsulConfigEntry[]> {
|
||||
return invoke("consul_mesh_config_list", { connectionId, kind });
|
||||
}
|
||||
export async function consulMeshConfigGet(connectionId: string, kind: string, name: string): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return invoke("consul_mesh_config_get", { connectionId, kind, name });
|
||||
}
|
||||
export async function consulMeshConfigApply(connectionId: string, request: import("@/types/consul").ConsulConfigEntryApply): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return invoke("consul_mesh_config_apply", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshConfigDelete(connectionId: string, kind: string, name: string, expectedModifyIndex: number): Promise<boolean> {
|
||||
return invoke("consul_mesh_config_delete", { connectionId, kind, name, expectedModifyIndex });
|
||||
}
|
||||
export async function consulMeshIntentionsList(connectionId: string): Promise<import("@/types/consul").ConsulIntention[]> {
|
||||
return invoke("consul_mesh_intentions_list", { connectionId });
|
||||
}
|
||||
export async function consulMeshIntentionGet(connectionId: string, id: string): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return invoke("consul_mesh_intention_get", { connectionId, id });
|
||||
}
|
||||
export async function consulMeshIntentionGetExact(connectionId: string, request: import("@/types/consul").ConsulIntentionExactRequest): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return invoke("consul_mesh_intention_get_exact", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshIntentionUpsert(connectionId: string, item: import("@/types/consul").ConsulIntention): Promise<import("@/types/consul").ConsulIntention> {
|
||||
return invoke("consul_mesh_intention_upsert", { connectionId, item });
|
||||
}
|
||||
export async function consulMeshIntentionDelete(connectionId: string, id: string): Promise<boolean> {
|
||||
return invoke("consul_mesh_intention_delete", { connectionId, id });
|
||||
}
|
||||
export async function consulMeshIntentionDeleteExact(connectionId: string, request: import("@/types/consul").ConsulIntentionExactRequest): Promise<boolean> {
|
||||
return invoke("consul_mesh_intention_delete_exact", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshIntentionMatch(connectionId: string, request: import("@/types/consul").ConsulIntentionMatchRequest): Promise<import("@/types/consul").ConsulIntention[]> {
|
||||
return invoke("consul_mesh_intention_match", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshIntentionCheck(connectionId: string, request: import("@/types/consul").ConsulIntentionCheckRequest): Promise<import("@/types/consul").ConsulIntentionCheckResponse> {
|
||||
return invoke("consul_mesh_intention_check", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshDiscoveryChain(connectionId: string, service: string): Promise<import("@/types/consul").ConsulDiscoveryChain> {
|
||||
return invoke("consul_mesh_discovery_chain", { connectionId, service });
|
||||
}
|
||||
export async function consulMeshPeeringList(connectionId: string): Promise<import("@/types/consul").ConsulPeering[]> {
|
||||
return invoke("consul_mesh_peering_list", { connectionId });
|
||||
}
|
||||
export async function consulMeshPeeringGet(connectionId: string, name: string): Promise<import("@/types/consul").ConsulPeering> {
|
||||
return invoke("consul_mesh_peering_get", { connectionId, name });
|
||||
}
|
||||
export async function consulMeshPeeringGenerateToken(connectionId: string, request: import("@/types/consul").ConsulPeeringGenerateRequest): Promise<import("@/types/consul").ConsulPeeringToken> {
|
||||
return invoke("consul_mesh_peering_generate_token", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshPeeringEstablish(connectionId: string, request: import("@/types/consul").ConsulPeeringEstablishRequest): Promise<import("@/types/consul").ConsulPeering> {
|
||||
return invoke("consul_mesh_peering_establish", { connectionId, request });
|
||||
}
|
||||
export async function consulMeshPeeringDelete(connectionId: string, name: string): Promise<boolean> {
|
||||
return invoke("consul_mesh_peering_delete", { connectionId, name });
|
||||
}
|
||||
export async function consulMeshExportedServicesList(connectionId: string): Promise<import("@/types/consul").ConsulExportedService[]> {
|
||||
return invoke("consul_mesh_exported_services_list", { connectionId });
|
||||
}
|
||||
export async function consulMeshExportedServicesApply(connectionId: string, name: string, expectedModifyIndex: number, raw: Record<string, unknown>): Promise<import("@/types/consul").ConsulConfigEntry> {
|
||||
return invoke("consul_mesh_exported_services_apply", { connectionId, name, expectedModifyIndex, raw });
|
||||
}
|
||||
|
||||
// --- HBase ---
|
||||
export async function hbaseGetTableSchema(connectionId: string, namespace: string, table: string): Promise<import("@/types/hbase").HBaseTableSchema> {
|
||||
return invoke("hbase_get_table_schema", { connectionId, namespace, table });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
|
||||
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "nacos-admin" } | { kind: "etcd" } | { kind: "zookeeper" } | { kind: "query"; database: string };
|
||||
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "nacos-admin" } | { kind: "etcd" } | { kind: "zookeeper" } | { kind: "consul" } | { kind: "query"; database: string };
|
||||
|
||||
export function quickConnectionOpenTarget(connection: Pick<ConnectionConfig, "db_type" | "database">, databaseOptions: string[] = []): QuickConnectionOpenTarget {
|
||||
if (connection.db_type === "mq") {
|
||||
@@ -16,5 +16,8 @@ export function quickConnectionOpenTarget(connection: Pick<ConnectionConfig, "db
|
||||
if (connection.db_type === "zookeeper") {
|
||||
return { kind: "zookeeper" };
|
||||
}
|
||||
if (connection.db_type === "consul") {
|
||||
return { kind: "consul" };
|
||||
}
|
||||
return { kind: "query", database: resolveDefaultDatabase(connection, databaseOptions) };
|
||||
}
|
||||
@@ -136,6 +136,7 @@ export function connectionDisplayUrlScheme(connection: Pick<ConnectionConfig, "d
|
||||
case "rqlite":
|
||||
case "turso":
|
||||
case "mq":
|
||||
case "consul":
|
||||
return connection.ssl ? "https" : "http";
|
||||
case "cloudflare-d1":
|
||||
return "https";
|
||||
@@ -171,6 +172,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
|
||||
case "zookeeper":
|
||||
return "zookeeper://host:2181";
|
||||
|
||||
case "consul":
|
||||
return "http://host:8500";
|
||||
|
||||
case "sqlite":
|
||||
return "sqlite:///absolute/path/to/database.db";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
|
||||
|
||||
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
|
||||
// non-database services expose their own root objects rather than database namespaces.
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]);
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
|
||||
type VisibleDatabaseConnectionFields = Pick<
|
||||
ConnectionConfig,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consulAgentAddressesMatch, consulAgentWriteTargetSafe } from "../agentTarget";
|
||||
|
||||
describe("Consul Agent target safety", () => {
|
||||
it("treats loopback aliases as equivalent", () => {
|
||||
expect(consulAgentAddressesMatch("localhost", "127.0.0.1")).toBe(true);
|
||||
expect(consulAgentAddressesMatch("::1", "localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects DNS and Transport targets in the UI gate", () => {
|
||||
const target = { agentTarget: { node: "node-1", address: "127.0.0.1" } };
|
||||
expect(consulAgentWriteTargetSafe({ external_config: { ...target, serverAddr: "https://consul.example" } }, "node-1")).toBe(false);
|
||||
expect(consulAgentWriteTargetSafe({ transport_layers: [{}], external_config: { ...target, serverAddr: "http://127.0.0.1:8500" } }, "node-1")).toBe(false);
|
||||
expect(consulAgentWriteTargetSafe({ external_config: { ...target, serverAddr: "http://localhost:8500" } }, "node-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { capabilityVisible, capabilityWritable, requireConfirmation, scopeHasWildcard } from "@/lib/consul/consulPolicy";
|
||||
import { nextWatchIndex } from "@/lib/consul/watchState";
|
||||
|
||||
describe("Consul domain policies", () => {
|
||||
it("keeps forbidden separate from unsupported", () => {
|
||||
expect(capabilityVisible("forbidden")).toBe(true);
|
||||
expect(capabilityVisible("unsupported")).toBe(false);
|
||||
expect(capabilityWritable("supported", false)).toBe(true);
|
||||
expect(capabilityWritable("supported", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires explicit wildcard scope and exact confirmations", () => {
|
||||
expect(scopeHasWildcard({ datacenter: "dc1", namespace: "*", partition: "default" })).toBe(true);
|
||||
expect(scopeHasWildcard({ datacenter: "dc1", namespace: "team", partition: "default" })).toBe(false);
|
||||
expect(requireConfirmation("DELETE team", "DELETE team")).toBe(true);
|
||||
expect(requireConfirmation("DELETE team", "delete team")).toBe(false);
|
||||
});
|
||||
|
||||
it("resets blocking indexes after zero or rollback", () => {
|
||||
expect(nextWatchIndex("50", "0")).toEqual({ index: "1", reset: true });
|
||||
expect(nextWatchIndex("50", "49")).toEqual({ index: "1", reset: true });
|
||||
expect(nextWatchIndex("50", "51")).toEqual({ index: "51", reset: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consulMeshWorkspaceVisible } from "@/lib/consul/meshVisibility";
|
||||
|
||||
describe("consulMeshWorkspaceVisible", () => {
|
||||
it("keeps the advanced workspace hidden unless explicitly enabled", () => {
|
||||
expect(consulMeshWorkspaceVisible({})).toBe(false);
|
||||
expect(consulMeshWorkspaceVisible(undefined)).toBe(false);
|
||||
expect(consulMeshWorkspaceVisible({ consulMeshVisible: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the workspace when the connection explicitly enables it", () => {
|
||||
expect(consulMeshWorkspaceVisible({ consulMeshVisible: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampConsulPage, consulPageCount, paginateConsulItems } from "@/lib/consul/pagination";
|
||||
|
||||
describe("Consul list pagination", () => {
|
||||
it("splits large result sets without dropping the final partial page", () => {
|
||||
const items = Array.from({ length: 123 }, (_, index) => index + 1);
|
||||
expect(consulPageCount(items.length, 50)).toBe(3);
|
||||
expect(paginateConsulItems(items, 1, 50)).toEqual(items.slice(0, 50));
|
||||
expect(paginateConsulItems(items, 3, 50)).toEqual(items.slice(100));
|
||||
});
|
||||
|
||||
it("clamps stale pages after filtering or deletion", () => {
|
||||
expect(clampConsulPage(9, 51, 50)).toBe(2);
|
||||
expect(clampConsulPage(0, 51, 50)).toBe(1);
|
||||
expect(clampConsulPage(Number.NaN, 0, 50)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeConsulSession } from "../sessionModel";
|
||||
|
||||
describe("Consul Session transport normalization", () => {
|
||||
it("accepts the legacy Checks response during a desktop backend hot restart", () => {
|
||||
const session = normalizeConsulSession({
|
||||
ID: "session-1",
|
||||
Name: "demo",
|
||||
Node: "node-1",
|
||||
LockDelay: 15_000_000_000,
|
||||
Behavior: "release",
|
||||
TTL: "",
|
||||
Checks: ["serfHealth"],
|
||||
CreateIndex: 12,
|
||||
});
|
||||
|
||||
expect(session.NodeChecks).toEqual(["serfHealth"]);
|
||||
expect(session.ServiceChecks).toEqual([]);
|
||||
expect(session.ModifyIndex).toBe(12);
|
||||
});
|
||||
|
||||
it("normalizes nullable Consul 2.0 check collections", () => {
|
||||
const session = normalizeConsulSession({
|
||||
ID: "session-2",
|
||||
NodeChecks: undefined,
|
||||
ServiceChecks: null as never,
|
||||
});
|
||||
|
||||
expect(session.NodeChecks).toEqual([]);
|
||||
expect(session.ServiceChecks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consulSessionRenewDelayMs, parseConsulDurationMs } from "../sessionRenew";
|
||||
|
||||
describe("Consul Session renew timing", () => {
|
||||
it("parses compound Consul durations", () => {
|
||||
expect(parseConsulDurationMs("1m30s")).toBe(90_000);
|
||||
expect(parseConsulDurationMs("250ms")).toBe(250);
|
||||
expect(parseConsulDurationMs("0s")).toBe(0);
|
||||
expect(parseConsulDurationMs("not-a-duration")).toBeNull();
|
||||
});
|
||||
|
||||
it("renews halfway through the TTL within safe timer bounds", () => {
|
||||
expect(consulSessionRenewDelayMs("10s")).toBe(5_000);
|
||||
expect(consulSessionRenewDelayMs("2m")).toBe(30_000);
|
||||
expect(consulSessionRenewDelayMs("250ms")).toBe(1_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCurrentWatchEvent, nextWatchIndex } from "../watchState";
|
||||
import type { ConsulWatchEvent } from "@/types/consul";
|
||||
|
||||
const event: ConsulWatchEvent = {
|
||||
connectionId: "connection-a",
|
||||
operationId: "watch-a",
|
||||
generation: 7,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
describe("Consul watch lifecycle", () => {
|
||||
it("resets zero and regressed indexes to the minimum blocking index", () => {
|
||||
expect(nextWatchIndex("15", "0")).toEqual({ index: "1", reset: true });
|
||||
expect(nextWatchIndex("15", "14")).toEqual({ index: "1", reset: true });
|
||||
expect(nextWatchIndex("15", "16")).toEqual({ index: "16", reset: false });
|
||||
});
|
||||
|
||||
it("rejects events from stale connections, operations, and scope generations", () => {
|
||||
expect(isCurrentWatchEvent({ connectionId: "connection-a", operationId: "watch-a", generation: 7 }, event)).toBe(true);
|
||||
expect(isCurrentWatchEvent({ connectionId: "connection-b", operationId: "watch-a", generation: 7 }, event)).toBe(false);
|
||||
expect(isCurrentWatchEvent({ connectionId: "connection-a", operationId: "watch-b", generation: 7 }, event)).toBe(false);
|
||||
expect(isCurrentWatchEvent({ connectionId: "connection-a", operationId: "watch-a", generation: 8 }, event)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
type AgentTargetConnection = {
|
||||
read_only?: boolean;
|
||||
transport_layers?: Array<{ enabled?: boolean }>;
|
||||
external_config?: unknown;
|
||||
};
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isIpv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
||||
}
|
||||
|
||||
function isIpv6(value: string): boolean {
|
||||
return value.includes(":") && /^[0-9a-f:]+$/i.test(value);
|
||||
}
|
||||
|
||||
function isLoopback(value: string): boolean {
|
||||
const address = normalizeAddress(value);
|
||||
return address === "localhost" || address === "::1" || (isIpv4(address) && address.startsWith("127."));
|
||||
}
|
||||
|
||||
export function consulAgentAddressesMatch(left: string, right: string): boolean {
|
||||
const a = normalizeAddress(left);
|
||||
const b = normalizeAddress(right);
|
||||
return a === b || (a === "localhost" && isLoopback(b)) || (b === "localhost" && isLoopback(a));
|
||||
}
|
||||
|
||||
export function consulAgentWriteTargetSafe(connection: AgentTargetConnection | undefined, identityNode: string | undefined): boolean {
|
||||
if (!connection || connection.read_only || connection.transport_layers?.some((layer) => layer.enabled !== false)) return false;
|
||||
const external = connection.external_config;
|
||||
if (!external || typeof external !== "object" || Array.isArray(external)) return false;
|
||||
const config = external as Record<string, unknown>;
|
||||
const rawTarget = config.agentTarget || config.agent_target;
|
||||
if (!rawTarget || typeof rawTarget !== "object" || Array.isArray(rawTarget)) return false;
|
||||
const target = rawTarget as Record<string, unknown>;
|
||||
const node = String(target.node || "").trim();
|
||||
const address = String(target.address || "").trim();
|
||||
if (!node || !address || node !== identityNode) return false;
|
||||
const serverAddress = String(config.serverAddr || config.server_addr || "").trim();
|
||||
try {
|
||||
const host = normalizeAddress(new URL(serverAddress).hostname);
|
||||
if (host !== "localhost" && !isIpv4(host) && !isIpv6(host)) return false;
|
||||
return consulAgentAddressesMatch(host, address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ConsulCapabilities, ConsulCapabilityStatus, ConsulScope } from "@/types/consul";
|
||||
|
||||
export function capabilityVisible(status: ConsulCapabilityStatus): boolean {
|
||||
return status !== "unsupported" && status !== "disabled";
|
||||
}
|
||||
|
||||
export function capabilityWritable(status: ConsulCapabilityStatus, readonly: boolean): boolean {
|
||||
return !readonly && status === "supported";
|
||||
}
|
||||
|
||||
export function scopeHasWildcard(scope: ConsulScope): boolean {
|
||||
return scope.datacenter === "*" || scope.namespace === "*" || scope.partition === "*";
|
||||
}
|
||||
|
||||
export function capabilityStatus(capabilities: ConsulCapabilities | null, key: keyof ConsulCapabilities): ConsulCapabilityStatus {
|
||||
const value = capabilities?.[key];
|
||||
return value === "supported" || value === "unsupported" || value === "disabled" || value === "forbidden" ? value : "unknown";
|
||||
}
|
||||
|
||||
export function requireConfirmation(expected: string, actual: string): boolean {
|
||||
return expected.length > 0 && actual.trim() === expected;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
function externalConfigRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export function consulMeshWorkspaceVisible(externalConfig: unknown): boolean {
|
||||
const config = externalConfigRecord(externalConfig);
|
||||
return config.consulMeshVisible === true;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CONSUL_LIST_PAGE_SIZE = 50;
|
||||
|
||||
export function consulPageCount(total: number, pageSize = CONSUL_LIST_PAGE_SIZE): number {
|
||||
const safeTotal = Math.max(0, Math.trunc(total));
|
||||
const safePageSize = Math.max(1, Math.trunc(pageSize));
|
||||
return Math.max(1, Math.ceil(safeTotal / safePageSize));
|
||||
}
|
||||
|
||||
export function clampConsulPage(page: number, total: number, pageSize = CONSUL_LIST_PAGE_SIZE): number {
|
||||
const safePage = Number.isFinite(page) ? Math.trunc(page) : 1;
|
||||
return Math.min(Math.max(1, safePage), consulPageCount(total, pageSize));
|
||||
}
|
||||
|
||||
export function paginateConsulItems<T>(items: readonly T[], page: number, pageSize = CONSUL_LIST_PAGE_SIZE): T[] {
|
||||
const safePageSize = Math.max(1, Math.trunc(pageSize));
|
||||
const safePage = clampConsulPage(page, items.length, safePageSize);
|
||||
const start = (safePage - 1) * safePageSize;
|
||||
return items.slice(start, start + safePageSize);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ConsulSession, ConsulSessionServiceCheck } from "@/types/consul";
|
||||
|
||||
type ConsulSessionWire = Partial<ConsulSession> & { Checks?: unknown };
|
||||
|
||||
export function normalizeConsulSession(value: ConsulSessionWire): ConsulSession {
|
||||
const nodeChecks = Array.isArray(value.NodeChecks) ? value.NodeChecks.filter((item): item is string => typeof item === "string") : Array.isArray(value.Checks) ? value.Checks.filter((item): item is string => typeof item === "string") : [];
|
||||
const serviceChecks = Array.isArray(value.ServiceChecks) ? value.ServiceChecks.filter((item): item is ConsulSessionServiceCheck => Boolean(item) && typeof item.ID === "string").map((item) => ({ ID: item.ID, Namespace: typeof item.Namespace === "string" ? item.Namespace : "" })) : [];
|
||||
return {
|
||||
ID: typeof value.ID === "string" ? value.ID : "",
|
||||
Name: typeof value.Name === "string" ? value.Name : "",
|
||||
Node: typeof value.Node === "string" ? value.Node : "",
|
||||
LockDelay: Number(value.LockDelay || 0),
|
||||
Behavior: typeof value.Behavior === "string" ? value.Behavior : "release",
|
||||
TTL: typeof value.TTL === "string" ? value.TTL : "",
|
||||
NodeChecks: nodeChecks,
|
||||
ServiceChecks: serviceChecks,
|
||||
Namespace: typeof value.Namespace === "string" ? value.Namespace : "",
|
||||
Partition: typeof value.Partition === "string" ? value.Partition : "",
|
||||
CreateIndex: Number(value.CreateIndex || 0),
|
||||
ModifyIndex: Number(value.ModifyIndex || value.CreateIndex || 0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const DURATION_PART = /(\d+(?:\.\d+)?)(ms|s|m|h)/gy;
|
||||
|
||||
export function parseConsulDurationMs(value: string): number | null {
|
||||
const input = value.trim();
|
||||
if (!input) return null;
|
||||
let total = 0;
|
||||
let offset = 0;
|
||||
for (const match of input.matchAll(DURATION_PART)) {
|
||||
if (match.index !== offset) return null;
|
||||
const amount = Number(match[1]);
|
||||
const multiplier = match[2] === "h" ? 3_600_000 : match[2] === "m" ? 60_000 : match[2] === "s" ? 1_000 : 1;
|
||||
total += amount * multiplier;
|
||||
offset += match[0].length;
|
||||
}
|
||||
return offset === input.length && Number.isFinite(total) && total >= 0 ? total : null;
|
||||
}
|
||||
|
||||
export function consulSessionRenewDelayMs(ttl: string): number {
|
||||
const ttlMs = parseConsulDurationMs(ttl) ?? 20_000;
|
||||
return Math.max(1_000, Math.min(30_000, Math.floor(ttlMs / 2)));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { KvInt64 } from "@/lib/backend/tauri";
|
||||
import type { ConsulWatchEvent } from "@/types/consul";
|
||||
|
||||
export interface ConsulWatchState {
|
||||
index: KvInt64 | null;
|
||||
connected: boolean;
|
||||
paused: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface ConsulWatchIdentity {
|
||||
connectionId: string;
|
||||
operationId: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export function isCurrentWatchEvent(identity: ConsulWatchIdentity, event: ConsulWatchEvent): boolean {
|
||||
return event.connectionId === identity.connectionId && event.operationId === identity.operationId && event.generation === identity.generation;
|
||||
}
|
||||
|
||||
export function nextWatchIndex(current: KvInt64 | null, returned: KvInt64 | null): { index: KvInt64; reset: boolean } {
|
||||
const currentValue = parseIndex(current) || 1n;
|
||||
const returnedValue = parseIndex(returned);
|
||||
if (returnedValue === 0n || returnedValue < currentValue) return { index: "1" as KvInt64, reset: true };
|
||||
return { index: returned ?? ("1" as KvInt64), reset: false };
|
||||
}
|
||||
|
||||
function parseIndex(index: KvInt64 | null): bigint {
|
||||
try {
|
||||
return BigInt(index ?? "0");
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function isColumnNameCopySeparator(value: unknown): value is ColumnNameCo
|
||||
}
|
||||
|
||||
// SQL 执行能力是产品能力,不代表数据库支持 SQL 标识符引用。
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "victoriametrics"]);
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
|
||||
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
|
||||
const UNQUOTABLE_DATABASE_TYPES = new Set<DatabaseType>(["jdbc", "iotdb"]);
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
|
||||
}
|
||||
|
||||
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
|
||||
return dbType !== "nacos" && dbType !== "hbase";
|
||||
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,6 +85,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
|
||||
jdbc: { deferred: "generic JDBC does not expose a reliable dialect-specific create target" },
|
||||
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
|
||||
nacos: { deferred: "Nacos namespace creation already uses the Nacos admin flow" },
|
||||
consul: { deferred: "Consul namespaces and partitions are connection scopes, not KV resources" },
|
||||
mqtt: { deferred: "MQTT topics are managed via the MQTT console" },
|
||||
} satisfies Record<DatabaseType, DatabaseNamespaceCreationMatrixEntry>;
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
|
||||
jdbc: { deferred: "generic JDBC does not expose reliable dialect-specific properties" },
|
||||
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
|
||||
nacos: { deferred: "Nacos namespace editing already uses the Nacos admin flow" },
|
||||
consul: { deferred: "Consul KV scopes are configured on the connection" },
|
||||
mqtt: { deferred: "MQTT topics are managed via the MQTT console" },
|
||||
} satisfies Record<DatabaseType, DatabasePropertyEditingEntry>;
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ export function classifyKvMutationError(error: unknown, creating: boolean, messa
|
||||
const sanitized = sanitizeKvMutationError(error);
|
||||
const normalized = sanitized.toLowerCase();
|
||||
|
||||
if (normalized.includes("etcd_key_already_exists") || (creating && normalized.includes("etcd_cas_conflict"))) {
|
||||
if (normalized.includes("etcd_key_already_exists") || normalized.includes("consul_key_already_exists") || (creating && (normalized.includes("etcd_cas_conflict") || normalized.includes("consul_cas_conflict")))) {
|
||||
return {
|
||||
kind: "keyAlreadyExists",
|
||||
message: messages.keyAlreadyExists || "A Key with this name already exists. Choose another name or edit the existing Key.",
|
||||
};
|
||||
}
|
||||
if (normalized.includes("etcd_cas_conflict")) {
|
||||
if (normalized.includes("etcd_cas_conflict") || normalized.includes("consul_cas_conflict")) {
|
||||
return {
|
||||
kind: "conflict",
|
||||
message: messages.conflict || "The Key changed after it was loaded. Refresh it and try again.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type KvRootKind = "etcd" | "zookeeper";
|
||||
export type KvRootKind = "etcd" | "zookeeper" | "consul";
|
||||
|
||||
export function kvRootNodeLabel(kind: KvRootKind): string {
|
||||
return kind === "zookeeper" ? "/" : "Keys";
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { KvKeyMetadata, KvKeySummary } from "@/lib/backend/api";
|
||||
|
||||
export interface LazyKvKeyTreeNode extends KvKeyMetadata {
|
||||
kind: "lazy";
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
pathSegments: string[];
|
||||
/**
|
||||
* `null` means that a separator-based Consul listing returned a path ending
|
||||
* in `/`, so an exact GET is still required to distinguish a real Key from a
|
||||
* virtual prefix. ZooKeeper nodes and non-directory Consul Keys are known
|
||||
* values immediately.
|
||||
*/
|
||||
hasValue: boolean | null;
|
||||
hasChildren: boolean;
|
||||
children: LazyKvKeyTreeNode[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
continuation: string | null;
|
||||
}
|
||||
|
||||
export interface LazyKvKeyTreeState {
|
||||
pathStyle: LazyKvPathStyle;
|
||||
rootPath: string;
|
||||
roots: LazyKvKeyTreeNode[];
|
||||
rootContinuation: string | null;
|
||||
nodeByKey: Map<string, LazyKvKeyTreeNode>;
|
||||
}
|
||||
|
||||
export type LazyKvPathStyle = "absolute" | "relative";
|
||||
|
||||
export type LazyKvKeySummary = KvKeySummary & { hasValue?: boolean | null };
|
||||
|
||||
export function lazyKvRootPath(pathStyle: LazyKvPathStyle): string {
|
||||
return pathStyle === "absolute" ? "/" : "";
|
||||
}
|
||||
|
||||
export function normalizeLazyKvPath(path: string, pathStyle: LazyKvPathStyle): string {
|
||||
if (pathStyle === "absolute") return normalizeZooKeeperPath(path);
|
||||
return path.trim().replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
export function parentLazyKvPath(path: string, pathStyle: LazyKvPathStyle): string {
|
||||
if (pathStyle === "absolute") return parentZooKeeperPath(path);
|
||||
const normalized = normalizeLazyKvPath(path, pathStyle);
|
||||
const withoutTrailingSlash = normalized.replace(/\/+$/, "");
|
||||
const separator = withoutTrailingSlash.lastIndexOf("/");
|
||||
return separator < 0 ? "" : `${withoutTrailingSlash.slice(0, separator)}/`;
|
||||
}
|
||||
|
||||
export function createLazyKvChildPathDraft(parentPath: string, pathStyle: LazyKvPathStyle): string {
|
||||
if (pathStyle === "absolute") return createZooKeeperChildPathDraft(parentPath);
|
||||
const normalized = normalizeLazyKvPath(parentPath, pathStyle);
|
||||
return normalized ? (normalized.endsWith("/") ? normalized : `${normalized}/`) : "";
|
||||
}
|
||||
|
||||
export type LazyKvKeyTreeRow =
|
||||
| {
|
||||
type: "node";
|
||||
node: LazyKvKeyTreeNode;
|
||||
depth: number;
|
||||
}
|
||||
| {
|
||||
type: "loadMore";
|
||||
id: string;
|
||||
parentKey: string | null;
|
||||
depth: number;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export function normalizeZooKeeperPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === "/") return "/";
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
return withSlash.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
export function parentZooKeeperPath(path: string): string {
|
||||
const normalized = normalizeZooKeeperPath(path);
|
||||
if (normalized === "/") return "/";
|
||||
const parent = normalized.slice(0, normalized.lastIndexOf("/"));
|
||||
return parent || "/";
|
||||
}
|
||||
|
||||
export function createZooKeeperChildPathDraft(parentPath: string): string {
|
||||
const normalized = normalizeZooKeeperPath(parentPath);
|
||||
if (normalized === "/") return "";
|
||||
return `${normalized}/`;
|
||||
}
|
||||
|
||||
export function createLazyKvKeyTreeState(rootPath = "/", pathStyle: LazyKvPathStyle = "absolute"): LazyKvKeyTreeState {
|
||||
return {
|
||||
pathStyle,
|
||||
rootPath: normalizeLazyKvPath(rootPath, pathStyle),
|
||||
roots: [],
|
||||
rootContinuation: null,
|
||||
nodeByKey: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetLazyKvKeyTree(state: LazyKvKeyTreeState, rootPath = lazyKvRootPath(state.pathStyle)) {
|
||||
state.rootPath = normalizeLazyKvPath(rootPath, state.pathStyle);
|
||||
state.roots = [];
|
||||
state.rootContinuation = null;
|
||||
state.nodeByKey.clear();
|
||||
}
|
||||
|
||||
export function replaceLazyKvChildren(state: LazyKvKeyTreeState, parentKey: string | null, keys: LazyKvKeySummary[], continuation: string | null | undefined, options: { append?: boolean; filteredByAcls?: boolean | null } = {}) {
|
||||
const previous = new Map(state.nodeByKey);
|
||||
const nextChildren = keys.map((key) => lazyNodeFromSummary(key, state.pathStyle, previous.get(key.key)));
|
||||
const children = options.append ? mergeLazyChildren(parentKey ? state.nodeByKey.get(parentKey)?.children || [] : state.roots, nextChildren) : nextChildren;
|
||||
|
||||
if (parentKey) {
|
||||
const parent = state.nodeByKey.get(parentKey);
|
||||
if (!parent) return;
|
||||
parent.children = children;
|
||||
parent.loaded = true;
|
||||
parent.continuation = continuation || null;
|
||||
// A separator listing cannot initially distinguish a real `key/` from a
|
||||
// virtual directory. Once its complete, unfiltered child listing is empty,
|
||||
// it is known to be a leaf and must no longer render an expander.
|
||||
if (children.length > 0 || continuation) parent.hasChildren = true;
|
||||
else if (!options.filteredByAcls) parent.hasChildren = false;
|
||||
} else {
|
||||
state.roots = children;
|
||||
state.rootContinuation = continuation || null;
|
||||
}
|
||||
|
||||
rebuildLazyNodeIndex(state);
|
||||
}
|
||||
|
||||
export function replaceLazyKvFocusedRoot(state: LazyKvKeyTreeState, root: LazyKvKeySummary, keys: LazyKvKeySummary[], continuation: string | null | undefined) {
|
||||
const previous = new Map(state.nodeByKey);
|
||||
const focusedPath = normalizeLazyKvPath(root.key, state.pathStyle);
|
||||
const childNodes = keys.map((key) => lazyNodeFromSummary(key, state.pathStyle, previous.get(key.key)));
|
||||
const chain = focusedPathChain(focusedPath, state.pathStyle);
|
||||
let rootNode: LazyKvKeyTreeNode | null = null;
|
||||
let parentNode: LazyKvKeyTreeNode | null = null;
|
||||
|
||||
for (const path of chain) {
|
||||
const isFocusedPath = path === focusedPath;
|
||||
const node = lazyNodeFromSummary(isFocusedPath ? { ...root, key: focusedPath } : { key: path, numChildren: 1, hasValue: false }, state.pathStyle, previous.get(path));
|
||||
// Ancestors are directories, but an exact focused Key with no children must
|
||||
// remain a leaf. Otherwise searching for that Key renders it as a folder.
|
||||
node.hasChildren = !isFocusedPath || (node.numChildren ?? 0) > 0;
|
||||
node.loaded = true;
|
||||
node.continuation = null;
|
||||
node.children = [];
|
||||
|
||||
if (parentNode) parentNode.children = [node];
|
||||
else rootNode = node;
|
||||
parentNode = node;
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.children = childNodes;
|
||||
parentNode.hasChildren = parentNode.hasChildren || childNodes.length > 0 || !!continuation;
|
||||
parentNode.continuation = continuation || null;
|
||||
}
|
||||
|
||||
state.roots = rootNode ? [rootNode] : [];
|
||||
state.rootContinuation = null;
|
||||
rebuildLazyNodeIndex(state);
|
||||
}
|
||||
|
||||
export function flattenLazyKvKeyTree(state: LazyKvKeyTreeState, expandedIds: ReadonlySet<string>): LazyKvKeyTreeRow[] {
|
||||
const rows = flattenLazyNodes(state.roots, expandedIds, 0);
|
||||
if (state.rootContinuation) {
|
||||
rows.push({ type: "loadMore", id: "lazy-load-more:root", parentKey: null, depth: 0, loading: false });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function lazyExpandedKeyFromId(id: string): string | null {
|
||||
return id.startsWith("lazy:") ? id.slice("lazy:".length) : null;
|
||||
}
|
||||
|
||||
function flattenLazyNodes(nodes: LazyKvKeyTreeNode[], expandedIds: ReadonlySet<string>, depth: number): LazyKvKeyTreeRow[] {
|
||||
const rows: LazyKvKeyTreeRow[] = [];
|
||||
for (const node of nodes) {
|
||||
rows.push({ type: "node", node, depth });
|
||||
if (node.hasChildren && expandedIds.has(node.id)) {
|
||||
rows.push(...flattenLazyNodes(node.children, expandedIds, depth + 1));
|
||||
if (node.continuation) {
|
||||
rows.push({ type: "loadMore", id: `lazy-load-more:${node.key}`, parentKey: node.key, depth: depth + 1, loading: node.loading });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function lazyNodeFromSummary(summary: LazyKvKeySummary, pathStyle: LazyKvPathStyle, previous?: LazyKvKeyTreeNode): LazyKvKeyTreeNode {
|
||||
const pathSegments = keySegments(summary.key);
|
||||
const hasValue = summary.hasValue !== undefined ? summary.hasValue : pathStyle === "absolute" || !summary.key.endsWith("/") ? true : (previous?.hasValue ?? null);
|
||||
return {
|
||||
...summary,
|
||||
kind: "lazy",
|
||||
id: `lazy:${summary.key}`,
|
||||
label: pathSegments[pathSegments.length - 1] || summary.key || "/",
|
||||
key: summary.key,
|
||||
pathSegments,
|
||||
hasValue,
|
||||
hasChildren: (summary.numChildren ?? 0) > 0 || summary.key.endsWith("/"),
|
||||
children: previous?.children || [],
|
||||
loaded: previous?.loaded || false,
|
||||
loading: previous?.loading || false,
|
||||
continuation: previous?.continuation || null,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLazyChildren(existing: LazyKvKeyTreeNode[], incoming: LazyKvKeyTreeNode[]): LazyKvKeyTreeNode[] {
|
||||
const seen = new Set(existing.map((node) => node.key));
|
||||
return [...existing, ...incoming.filter((node) => !seen.has(node.key))];
|
||||
}
|
||||
|
||||
function rebuildLazyNodeIndex(state: LazyKvKeyTreeState) {
|
||||
state.nodeByKey.clear();
|
||||
const visit = (nodes: LazyKvKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
state.nodeByKey.set(node.key, node);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(state.roots);
|
||||
}
|
||||
|
||||
function keySegments(key: string): string[] {
|
||||
return key.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function focusedPathChain(path: string, pathStyle: LazyKvPathStyle): string[] {
|
||||
const segments = keySegments(path);
|
||||
const trailingSlash = pathStyle === "relative" && path.endsWith("/");
|
||||
const chain: string[] = [];
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const joined = segments.slice(0, index + 1).join("/");
|
||||
const isLeaf = index === segments.length - 1;
|
||||
chain.push(pathStyle === "absolute" ? `/${joined}` : isLeaf && !trailingSlash ? joined : `${joined}/`);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
@@ -48,6 +48,14 @@ export type ActiveTabSidebarTarget =
|
||||
type: "zookeeper-root";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "consul-root";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "consul-overview";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "mq-tenant";
|
||||
connectionId: string;
|
||||
@@ -147,6 +155,12 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
|
||||
if (tab.mode === "zookeeper") {
|
||||
return { type: "zookeeper-root", connectionId: tab.connectionId };
|
||||
}
|
||||
if (tab.mode === "consul") {
|
||||
return { type: "consul-root", connectionId: tab.connectionId };
|
||||
}
|
||||
if (tab.mode === "consul-overview") {
|
||||
return { type: "consul-overview", connectionId: tab.connectionId };
|
||||
}
|
||||
|
||||
if (tab.mode === "mq" && tab.mqTenant) {
|
||||
return { type: "mq-tenant", connectionId: tab.connectionId, tenant: tab.mqTenant };
|
||||
@@ -226,6 +240,12 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
|
||||
if (target.type === "zookeeper-root") {
|
||||
return node.type === "zookeeper-root" && node.connectionId === target.connectionId;
|
||||
}
|
||||
if (target.type === "consul-root") {
|
||||
return node.type === "consul-root" && node.connectionId === target.connectionId;
|
||||
}
|
||||
if (target.type === "consul-overview") {
|
||||
return node.type === "consul-overview" && node.connectionId === target.connectionId;
|
||||
}
|
||||
|
||||
if (target.type === "mq-tenant") {
|
||||
return node.type === "mq-tenant" && node.connectionId === target.connectionId && (node.mqTenant || node.label) === target.tenant;
|
||||
|
||||
@@ -19,6 +19,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
|
||||
"etcd-dashboard",
|
||||
"etcd-access-control",
|
||||
"zookeeper-root",
|
||||
"consul-root",
|
||||
"consul-overview",
|
||||
"mongo-gridfs",
|
||||
"mongo-bucket",
|
||||
"vector-collection",
|
||||
|
||||
@@ -8,7 +8,35 @@ export type SidebarActivation = "single" | "double";
|
||||
|
||||
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
|
||||
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket"]);
|
||||
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "mqtt-topic", "etcd-root", "etcd-dashboard", "etcd-access-control", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
|
||||
const toggleLeafNodeTypes = new Set<TreeNodeType>([
|
||||
"redis-db",
|
||||
"mq-tenant",
|
||||
"mqtt-topic",
|
||||
"etcd-root",
|
||||
"etcd-dashboard",
|
||||
"etcd-access-control",
|
||||
"zookeeper-root",
|
||||
"consul-root",
|
||||
"consul-overview",
|
||||
"mongo-gridfs",
|
||||
"mongo-collection",
|
||||
"mongo-bucket",
|
||||
"vector-collection",
|
||||
"elasticsearch-index",
|
||||
"user-admin",
|
||||
]);
|
||||
// These are application entry points rather than database objects. They should
|
||||
// always navigate on a single click, even when the user prefers double-click
|
||||
// activation for ordinary tree objects.
|
||||
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview"]);
|
||||
|
||||
export function isDirectNavigationTreeNode(type: TreeNodeType): boolean {
|
||||
return directNavigationTreeNodeTypes.has(type);
|
||||
}
|
||||
|
||||
export function shouldActivateTreeNodeOnSingleClick(type: TreeNodeType, activation: SidebarActivation = "single"): boolean {
|
||||
return activation !== "double" || isDirectNavigationTreeNode(type);
|
||||
}
|
||||
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser"]);
|
||||
const sourceNodeTypes = new Set<TreeNodeType>(["materialized_view", "procedure", "function", "trigger", "sequence", "synonym", "package", "package-body", "type", "type-body"]);
|
||||
const savedSqlNodeTypes = new Set<TreeNodeType>(["saved-sql-file"]);
|
||||
@@ -35,7 +63,7 @@ export function isDocumentBrowserTreeNode(type: TreeNodeType): boolean {
|
||||
}
|
||||
|
||||
export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single"): TreeNodeRowAction {
|
||||
if (activation === "double") return "none";
|
||||
if (!shouldActivateTreeNodeOnSingleClick(type, activation)) return "none";
|
||||
if (type === "extension") return "open-extension-details";
|
||||
if (dataNodeTypes.has(type)) return "open-data";
|
||||
if (sourceNodeTypes.has(type)) return "open-source";
|
||||
|
||||
@@ -36,6 +36,7 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
|
||||
case "etcd-access-control":
|
||||
return { icon: ShieldCheck, colorClass: "text-sky-500" };
|
||||
case "zookeeper-root":
|
||||
case "consul-root":
|
||||
return { icon: Database, colorClass: "text-blue-500" };
|
||||
case "table":
|
||||
return { icon: Table, colorClass: "text-green-500" };
|
||||
|
||||
@@ -22,7 +22,7 @@ export function elasticsearchRestRequestRanges(sql: string, databaseType?: Datab
|
||||
return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
|
||||
}
|
||||
|
||||
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "neo4j", "victoriametrics"]);
|
||||
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
|
||||
|
||||
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
|
||||
return !!databaseType && (databaseType === "redis" || isElasticsearchCompatibleDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
|
||||
|
||||
@@ -119,6 +119,14 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
|
||||
if (compact) return connectionDisplayName(tab.connectionId);
|
||||
return `${connectionDisplayName(tab.connectionId)}@keys`;
|
||||
}
|
||||
if (tab.mode === "consul") {
|
||||
if (compact) return connectionDisplayName(tab.connectionId);
|
||||
return `${connectionDisplayName(tab.connectionId)}@keys`;
|
||||
}
|
||||
if (tab.mode === "consul-overview") {
|
||||
if (compact) return connectionDisplayName(tab.connectionId);
|
||||
return `${connectionDisplayName(tab.connectionId)}@${t("consul.ui.overview")}`;
|
||||
}
|
||||
if (tab.mode === "objects") {
|
||||
const schema = tab.objectBrowser?.schema;
|
||||
if (compact) return schema || tab.title;
|
||||
@@ -418,6 +426,8 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
|
||||
if (tab.mode === "etcd-dashboard") return t("tabs.etcdDashboard");
|
||||
if (tab.mode === "etcd-access-control") return t("tabs.etcdAccessControl");
|
||||
if (tab.mode === "zookeeper") return t("tabs.zookeeper");
|
||||
if (tab.mode === "consul") return t("tabs.consul");
|
||||
if (tab.mode === "consul-overview") return t("consul.ui.overview");
|
||||
if (tab.mode === "nacos") return "Nacos";
|
||||
if (tab.mode === "objects") return t("tabs.objects");
|
||||
if (tab.mode === "users") return t("tabs.users");
|
||||
|
||||
@@ -1,193 +1 @@
|
||||
import type { KvKeyMetadata, KvKeySummary } from "@/lib/backend/api";
|
||||
|
||||
export interface LazyKvKeyTreeNode extends KvKeyMetadata {
|
||||
kind: "lazy";
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
pathSegments: string[];
|
||||
hasChildren: boolean;
|
||||
children: LazyKvKeyTreeNode[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
continuation: string | null;
|
||||
}
|
||||
|
||||
export interface LazyKvKeyTreeState {
|
||||
rootPath: string;
|
||||
roots: LazyKvKeyTreeNode[];
|
||||
rootContinuation: string | null;
|
||||
nodeByKey: Map<string, LazyKvKeyTreeNode>;
|
||||
}
|
||||
|
||||
export type LazyKvKeyTreeRow =
|
||||
| {
|
||||
type: "node";
|
||||
node: LazyKvKeyTreeNode;
|
||||
depth: number;
|
||||
}
|
||||
| {
|
||||
type: "loadMore";
|
||||
id: string;
|
||||
parentKey: string | null;
|
||||
depth: number;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export function normalizeZooKeeperPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === "/") return "/";
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
return withSlash.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
export function parentZooKeeperPath(path: string): string {
|
||||
const normalized = normalizeZooKeeperPath(path);
|
||||
if (normalized === "/") return "/";
|
||||
const parent = normalized.slice(0, normalized.lastIndexOf("/"));
|
||||
return parent || "/";
|
||||
}
|
||||
|
||||
export function createZooKeeperChildPathDraft(parentPath: string): string {
|
||||
const normalized = normalizeZooKeeperPath(parentPath);
|
||||
if (normalized === "/") return "";
|
||||
return `${normalized}/`;
|
||||
}
|
||||
|
||||
export function createLazyKvKeyTreeState(rootPath = "/"): LazyKvKeyTreeState {
|
||||
return {
|
||||
rootPath: normalizeZooKeeperPath(rootPath),
|
||||
roots: [],
|
||||
rootContinuation: null,
|
||||
nodeByKey: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetLazyKvKeyTree(state: LazyKvKeyTreeState, rootPath = "/") {
|
||||
state.rootPath = normalizeZooKeeperPath(rootPath);
|
||||
state.roots = [];
|
||||
state.rootContinuation = null;
|
||||
state.nodeByKey.clear();
|
||||
}
|
||||
|
||||
export function replaceLazyKvChildren(state: LazyKvKeyTreeState, parentKey: string | null, keys: KvKeySummary[], continuation: string | null | undefined, options: { append?: boolean } = {}) {
|
||||
const previous = new Map(state.nodeByKey);
|
||||
const nextChildren = keys.map((key) => lazyNodeFromSummary(key, previous.get(key.key)));
|
||||
const children = options.append ? mergeLazyChildren(parentKey ? state.nodeByKey.get(parentKey)?.children || [] : state.roots, nextChildren) : nextChildren;
|
||||
|
||||
if (parentKey) {
|
||||
const parent = state.nodeByKey.get(parentKey);
|
||||
if (!parent) return;
|
||||
parent.children = children;
|
||||
parent.loaded = true;
|
||||
parent.continuation = continuation || null;
|
||||
} else {
|
||||
state.roots = children;
|
||||
state.rootContinuation = continuation || null;
|
||||
}
|
||||
|
||||
rebuildLazyNodeIndex(state);
|
||||
}
|
||||
|
||||
export function replaceLazyKvFocusedRoot(state: LazyKvKeyTreeState, root: KvKeySummary, keys: KvKeySummary[], continuation: string | null | undefined) {
|
||||
const previous = new Map(state.nodeByKey);
|
||||
const focusedPath = normalizeZooKeeperPath(root.key);
|
||||
const childNodes = keys.map((key) => lazyNodeFromSummary(key, previous.get(key.key)));
|
||||
const chain = focusedPathChain(focusedPath);
|
||||
let rootNode: LazyKvKeyTreeNode | null = null;
|
||||
let parentNode: LazyKvKeyTreeNode | null = null;
|
||||
|
||||
for (const path of chain) {
|
||||
const node = lazyNodeFromSummary(path === focusedPath ? { ...root, key: focusedPath } : { key: path, numChildren: 1 }, previous.get(path));
|
||||
node.hasChildren = true;
|
||||
node.loaded = true;
|
||||
node.continuation = null;
|
||||
node.children = [];
|
||||
|
||||
if (parentNode) parentNode.children = [node];
|
||||
else rootNode = node;
|
||||
parentNode = node;
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.children = childNodes;
|
||||
parentNode.hasChildren = parentNode.hasChildren || childNodes.length > 0 || !!continuation;
|
||||
parentNode.continuation = continuation || null;
|
||||
}
|
||||
|
||||
state.roots = rootNode ? [rootNode] : [];
|
||||
state.rootContinuation = null;
|
||||
rebuildLazyNodeIndex(state);
|
||||
}
|
||||
|
||||
export function flattenLazyKvKeyTree(state: LazyKvKeyTreeState, expandedIds: ReadonlySet<string>): LazyKvKeyTreeRow[] {
|
||||
const rows = flattenLazyNodes(state.roots, expandedIds, 0);
|
||||
if (state.rootContinuation) {
|
||||
rows.push({ type: "loadMore", id: "lazy-load-more:root", parentKey: null, depth: 0, loading: false });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function lazyExpandedKeyFromId(id: string): string | null {
|
||||
return id.startsWith("lazy:") ? id.slice("lazy:".length) : null;
|
||||
}
|
||||
|
||||
function flattenLazyNodes(nodes: LazyKvKeyTreeNode[], expandedIds: ReadonlySet<string>, depth: number): LazyKvKeyTreeRow[] {
|
||||
const rows: LazyKvKeyTreeRow[] = [];
|
||||
for (const node of nodes) {
|
||||
rows.push({ type: "node", node, depth });
|
||||
if (node.hasChildren && expandedIds.has(node.id)) {
|
||||
rows.push(...flattenLazyNodes(node.children, expandedIds, depth + 1));
|
||||
if (node.continuation) {
|
||||
rows.push({ type: "loadMore", id: `lazy-load-more:${node.key}`, parentKey: node.key, depth: depth + 1, loading: node.loading });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function lazyNodeFromSummary(summary: KvKeySummary, previous?: LazyKvKeyTreeNode): LazyKvKeyTreeNode {
|
||||
const pathSegments = keySegments(summary.key);
|
||||
return {
|
||||
...summary,
|
||||
kind: "lazy",
|
||||
id: `lazy:${summary.key}`,
|
||||
label: pathSegments[pathSegments.length - 1] || summary.key || "/",
|
||||
key: summary.key,
|
||||
pathSegments,
|
||||
hasChildren: (summary.numChildren ?? 0) > 0,
|
||||
children: previous?.children || [],
|
||||
loaded: previous?.loaded || false,
|
||||
loading: previous?.loading || false,
|
||||
continuation: previous?.continuation || null,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLazyChildren(existing: LazyKvKeyTreeNode[], incoming: LazyKvKeyTreeNode[]): LazyKvKeyTreeNode[] {
|
||||
const seen = new Set(existing.map((node) => node.key));
|
||||
return [...existing, ...incoming.filter((node) => !seen.has(node.key))];
|
||||
}
|
||||
|
||||
function rebuildLazyNodeIndex(state: LazyKvKeyTreeState) {
|
||||
state.nodeByKey.clear();
|
||||
const visit = (nodes: LazyKvKeyTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
state.nodeByKey.set(node.key, node);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(state.roots);
|
||||
}
|
||||
|
||||
function keySegments(key: string): string[] {
|
||||
return key.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function focusedPathChain(path: string): string[] {
|
||||
const segments = keySegments(path);
|
||||
const chain: string[] = [];
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
chain.push(`/${segments.slice(0, index + 1).join("/")}`);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
export * from "@/lib/kv/slashPrefixLazyKeyTree";
|
||||
@@ -985,6 +985,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
redis: "Redis",
|
||||
etcd: "etcd",
|
||||
zookeeper: "Apache ZooKeeper",
|
||||
consul: "Consul",
|
||||
duckdb: "DuckDB",
|
||||
clickhouse: "ClickHouse",
|
||||
sqlserver: "SQL Server",
|
||||
@@ -2744,6 +2745,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadEtcdRoot(connectionId);
|
||||
} else if (config.db_type === "zookeeper") {
|
||||
await loadZooKeeperRoot(connectionId);
|
||||
} else if (config.db_type === "consul") {
|
||||
await loadConsulRoot(connectionId);
|
||||
} else if (config.db_type === "mongodb") {
|
||||
await loadMongoDatabases(connectionId);
|
||||
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch") {
|
||||
@@ -3291,7 +3294,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
|
||||
if (!connectedIds.value.has(connectionId)) return;
|
||||
const config = getConfig(connectionId);
|
||||
if (!config || ["redis", "etcd", "zookeeper", "mongodb", "elasticsearch", "easysearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
|
||||
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "elasticsearch", "easysearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node || node.type !== "connection" || node.isLoading || hasConnectionMetadataChildren(node.children)) return;
|
||||
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
|
||||
@@ -3450,6 +3453,52 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConsulRoot(connectionId: string) {
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node) return;
|
||||
|
||||
let load = beginTreeNodeLoad(node);
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
load = reclaimTreeNodeLoad(load, node);
|
||||
const targetNode = treeNodeLoadTarget(load);
|
||||
if (!targetNode) return;
|
||||
setChildren(
|
||||
targetNode,
|
||||
withSavedSqlRoot(
|
||||
connectionId,
|
||||
[
|
||||
{
|
||||
id: `${connectionId}:consul`,
|
||||
label: kvRootNodeLabel("consul"),
|
||||
type: "consul-root" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: `${connectionId}:consul-overview`,
|
||||
label: i18n.global.t("consul.ui.overview"),
|
||||
type: "consul-overview" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
targetNode,
|
||||
),
|
||||
);
|
||||
targetNode.isExpanded = true;
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e, load);
|
||||
throw e;
|
||||
} finally {
|
||||
finishTreeNodeLoad(load);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMqttTopics(connectionId: string) {
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node) return;
|
||||
@@ -5238,6 +5287,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadEtcdRoot(node.connectionId);
|
||||
} else if (config?.db_type === "zookeeper") {
|
||||
await loadZooKeeperRoot(node.connectionId);
|
||||
} else if (config?.db_type === "consul") {
|
||||
await loadConsulRoot(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
@@ -7152,6 +7203,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
refreshRedisDbKeyCounts,
|
||||
loadEtcdRoot,
|
||||
loadZooKeeperRoot,
|
||||
loadConsulRoot,
|
||||
loadMqTenants,
|
||||
loadMqttTopics,
|
||||
loadNacosNamespaces,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConsulScope } from "@/types/consul";
|
||||
|
||||
function emptyScope(): ConsulScope {
|
||||
return { datacenter: "", namespace: "", partition: "" };
|
||||
}
|
||||
|
||||
export const useConsulStore = defineStore("consul", () => {
|
||||
const activeConnectionId = ref("");
|
||||
const scope = reactive<ConsulScope>(emptyScope());
|
||||
const generation = ref(0);
|
||||
const activeOperations = reactive(new Map<string, { connectionId: string; scope: ConsulScope; generation: number }>());
|
||||
|
||||
const scopeKey = computed(() => `${scope.datacenter}\0${scope.partition}\0${scope.namespace}`);
|
||||
|
||||
function bindConnection(connectionId: string, nextScope: ConsulScope = emptyScope()) {
|
||||
if (activeConnectionId.value === connectionId && scopeKey.value === scopeIdentity(nextScope)) return;
|
||||
invalidate();
|
||||
activeConnectionId.value = connectionId;
|
||||
Object.assign(scope, nextScope);
|
||||
}
|
||||
|
||||
function switchScope(nextScope: ConsulScope) {
|
||||
if (scopeKey.value === scopeIdentity(nextScope)) return;
|
||||
invalidate();
|
||||
Object.assign(scope, nextScope);
|
||||
}
|
||||
|
||||
function registerOperation(operationId: string) {
|
||||
if (activeOperations.has(operationId)) throw new Error("CONSUL_OPERATION_ALREADY_RUNNING");
|
||||
activeOperations.set(operationId, {
|
||||
connectionId: activeConnectionId.value,
|
||||
scope: { ...scope },
|
||||
generation: generation.value,
|
||||
});
|
||||
}
|
||||
|
||||
function completeOperation(operationId: string) {
|
||||
activeOperations.delete(operationId);
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
for (const [operationId, operation] of activeOperations) {
|
||||
void api.consulCancelBlocking(operation.connectionId, operation.scope, operation.generation, operationId).catch(() => undefined);
|
||||
}
|
||||
activeOperations.clear();
|
||||
generation.value += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
activeConnectionId,
|
||||
scope,
|
||||
scopeKey,
|
||||
generation,
|
||||
bindConnection,
|
||||
switchScope,
|
||||
registerOperation,
|
||||
completeOperation,
|
||||
invalidate,
|
||||
};
|
||||
});
|
||||
|
||||
function scopeIdentity(scope: ConsulScope): string {
|
||||
return `${scope.datacenter}\0${scope.partition}\0${scope.namespace}`;
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
import type { KvInt64, KvValue } from "@/lib/backend/tauri";
|
||||
|
||||
export type ConsulCapabilityStatus = "supported" | "unsupported" | "disabled" | "forbidden" | "unknown";
|
||||
|
||||
export interface ConsulScope {
|
||||
datacenter: string;
|
||||
namespace: string;
|
||||
partition: string;
|
||||
}
|
||||
|
||||
export interface ConsulResponseMetadata {
|
||||
index: KvInt64 | null;
|
||||
filteredByAcls: boolean | null;
|
||||
knownLeader: boolean | null;
|
||||
lastContact: KvInt64 | null;
|
||||
queryBackend: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulCapabilities {
|
||||
version: string | null;
|
||||
datacenter: string | null;
|
||||
nodeName: string | null;
|
||||
server: boolean | null;
|
||||
edition: string | null;
|
||||
agent: ConsulCapabilityStatus;
|
||||
catalog: ConsulCapabilityStatus;
|
||||
health: ConsulCapabilityStatus;
|
||||
sessions: ConsulCapabilityStatus;
|
||||
acl: ConsulCapabilityStatus;
|
||||
authMethods: ConsulCapabilityStatus;
|
||||
bindingRules: ConsulCapabilityStatus;
|
||||
templatedPolicies: ConsulCapabilityStatus;
|
||||
namespaces: ConsulCapabilityStatus;
|
||||
partitions: ConsulCapabilityStatus;
|
||||
configEntries: ConsulCapabilityStatus;
|
||||
intentions: ConsulCapabilityStatus;
|
||||
peering: ConsulCapabilityStatus;
|
||||
exportedServices: ConsulCapabilityStatus;
|
||||
preparedQueries: ConsulCapabilityStatus;
|
||||
events: ConsulCapabilityStatus;
|
||||
coordinates: ConsulCapabilityStatus;
|
||||
operatorAutopilot: ConsulCapabilityStatus;
|
||||
operatorRaft: ConsulCapabilityStatus;
|
||||
operatorKeyring: ConsulCapabilityStatus;
|
||||
operatorUsage: ConsulCapabilityStatus;
|
||||
operatorLicense: ConsulCapabilityStatus;
|
||||
audit: ConsulCapabilityStatus;
|
||||
}
|
||||
|
||||
export interface ConsulKvRecord {
|
||||
key: string;
|
||||
value: KvValue;
|
||||
flags: KvInt64;
|
||||
createIndex: KvInt64;
|
||||
modifyIndex: KvInt64;
|
||||
lockIndex: KvInt64;
|
||||
session: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulRecursiveListResponse {
|
||||
entries: ConsulKvRecord[];
|
||||
index: KvInt64 | null;
|
||||
filteredByAcls: boolean | null;
|
||||
totalValueBytes: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulSearchRequest {
|
||||
requestId: string;
|
||||
prefix: string;
|
||||
query: string;
|
||||
searchKeys: boolean;
|
||||
searchValues: boolean;
|
||||
caseSensitive: boolean;
|
||||
limit: number;
|
||||
maxScan: number;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface ConsulSearchMatch extends ConsulKvRecord {
|
||||
matchesKey: boolean;
|
||||
matchesValue: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulSearchResponse {
|
||||
matches: ConsulSearchMatch[];
|
||||
scanned: number;
|
||||
matched: number;
|
||||
limited: boolean;
|
||||
filteredByAcls: boolean | null;
|
||||
}
|
||||
|
||||
export interface ConsulSearchProgress {
|
||||
running: boolean;
|
||||
scanned: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export type ConsulExportScopeKind = "key" | "prefix";
|
||||
|
||||
export interface ConsulExportRequest {
|
||||
path: string;
|
||||
kind: ConsulExportScopeKind;
|
||||
}
|
||||
|
||||
export interface ConsulBundleScope {
|
||||
datacenter: string;
|
||||
namespace: string;
|
||||
partition: string;
|
||||
}
|
||||
|
||||
export interface ConsulBundleEntry {
|
||||
key: string;
|
||||
value: KvValue;
|
||||
flags: KvInt64;
|
||||
createIndex: KvInt64 | null;
|
||||
modifyIndex: KvInt64 | null;
|
||||
}
|
||||
|
||||
export interface ConsulKvBundle {
|
||||
format: "dbx-consul-kv-bundle";
|
||||
version: 1;
|
||||
exportedAtUnixMs: number;
|
||||
prefix: string;
|
||||
scopeKind: ConsulExportScopeKind;
|
||||
source: ConsulBundleScope;
|
||||
entries: ConsulBundleEntry[];
|
||||
}
|
||||
|
||||
export type ConsulImportConflictPolicy = "abort" | "skip" | "cas";
|
||||
export type ConsulImportOperation = "create" | "update" | "unchanged" | "skipped" | "conflict" | "locked";
|
||||
|
||||
export interface ConsulImportRequest {
|
||||
bundle: ConsulKvBundle;
|
||||
policy: ConsulImportConflictPolicy;
|
||||
previewId?: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulImportPreviewRow {
|
||||
key: string;
|
||||
operation: ConsulImportOperation;
|
||||
expectedModifyIndex: KvInt64 | null;
|
||||
targetSession: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulImportPreview {
|
||||
previewId: string;
|
||||
rows: ConsulImportPreviewRow[];
|
||||
canApply: boolean;
|
||||
creates: number;
|
||||
updates: number;
|
||||
unchanged: number;
|
||||
skipped: number;
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
export type ConsulImportOutcome = "succeeded" | "conflicted" | "skipped" | "failed";
|
||||
|
||||
export interface ConsulImportResultItem {
|
||||
key: string;
|
||||
outcome: ConsulImportOutcome;
|
||||
message: string | null;
|
||||
batch: number | null;
|
||||
opIndex: number | null;
|
||||
}
|
||||
|
||||
export interface ConsulImportReport {
|
||||
items: ConsulImportResultItem[];
|
||||
succeeded: number;
|
||||
conflicted: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
atomic: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulDeleteCandidate {
|
||||
key: string;
|
||||
modifyIndex: KvInt64;
|
||||
session: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulDeletePrefixPreview {
|
||||
prefix: string;
|
||||
candidates: ConsulDeleteCandidate[];
|
||||
filteredByAcls: boolean | null;
|
||||
complete: boolean;
|
||||
canExecute: boolean;
|
||||
locked: number;
|
||||
}
|
||||
|
||||
export interface ConsulDeletePrefixRequest {
|
||||
prefix: string;
|
||||
expected: ConsulDeleteCandidate[];
|
||||
}
|
||||
|
||||
export type ConsulDeleteOutcome = "succeeded" | "conflicted" | "failed";
|
||||
|
||||
export interface ConsulDeleteResultItem {
|
||||
key: string;
|
||||
outcome: ConsulDeleteOutcome;
|
||||
message: string | null;
|
||||
batch: number | null;
|
||||
opIndex: number | null;
|
||||
}
|
||||
|
||||
export type ConsulTxnVerb = "get" | "get-tree" | "set" | "cas" | "delete" | "delete-cas" | "delete-tree" | "lock" | "unlock" | "check-index" | "check-not-exists" | "check-session";
|
||||
|
||||
export interface ConsulTxnKvOperation {
|
||||
verb: ConsulTxnVerb;
|
||||
key: string;
|
||||
value: KvValue | null;
|
||||
flags: KvInt64 | null;
|
||||
index: KvInt64 | null;
|
||||
session: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulTxnRequest {
|
||||
operations: ConsulTxnKvOperation[];
|
||||
}
|
||||
export interface ConsulTxnError {
|
||||
opIndex: number;
|
||||
message: string;
|
||||
}
|
||||
export interface ConsulTxnKvResult {
|
||||
key: string;
|
||||
value: KvValue | null;
|
||||
flags: KvInt64;
|
||||
createIndex: KvInt64;
|
||||
modifyIndex: KvInt64;
|
||||
lockIndex: KvInt64;
|
||||
session: string | null;
|
||||
}
|
||||
export interface ConsulTxnResult {
|
||||
committed: boolean;
|
||||
errors: ConsulTxnError[];
|
||||
results: ConsulTxnKvResult[];
|
||||
}
|
||||
|
||||
export interface ConsulBlockingRequest {
|
||||
operationId: string;
|
||||
generation: number;
|
||||
key: string;
|
||||
prefix: boolean;
|
||||
index: KvInt64 | null;
|
||||
waitSeconds: number;
|
||||
}
|
||||
|
||||
export interface ConsulBlockingResponse {
|
||||
entries: ConsulKvRecord[];
|
||||
metadata: ConsulResponseMetadata;
|
||||
changed: boolean;
|
||||
timedOut: boolean;
|
||||
indexReset: boolean;
|
||||
}
|
||||
|
||||
export type ConsulDomainWatchTarget =
|
||||
| { kind: "catalogNodes" }
|
||||
| { kind: "catalogServices" }
|
||||
| { kind: "catalogServiceNodes"; service: string }
|
||||
| { kind: "catalogNodeServices"; node: string }
|
||||
| { kind: "healthNode"; node: string }
|
||||
| { kind: "healthServiceChecks"; service: string }
|
||||
| { kind: "healthServiceInstances"; service: string; passing: boolean | null }
|
||||
| { kind: "healthState"; state: string };
|
||||
|
||||
export interface ConsulDomainWatchRequest {
|
||||
operationId: string;
|
||||
generation: number;
|
||||
target: ConsulDomainWatchTarget;
|
||||
index: KvInt64 | null;
|
||||
waitSeconds: number;
|
||||
}
|
||||
|
||||
export type ConsulDomainWatchItems = ConsulCatalogNode[] | Record<string, string[]> | ConsulCatalogServiceNode[] | ConsulNodeServices | ConsulHealthCheck[] | ConsulServiceInstance[];
|
||||
|
||||
export interface ConsulDomainWatchResponse<T extends ConsulDomainWatchItems> {
|
||||
items: T;
|
||||
metadata: ConsulResponseMetadata;
|
||||
changed: boolean;
|
||||
timedOut: boolean;
|
||||
indexReset: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulWatchEvent {
|
||||
connectionId: string;
|
||||
operationId: string;
|
||||
generation: number;
|
||||
result: ConsulBlockingResponse | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulPreparedQueryService {
|
||||
Service: string;
|
||||
Near: string;
|
||||
OnlyPassing: boolean;
|
||||
Tags: string[];
|
||||
}
|
||||
export interface ConsulPreparedQuery {
|
||||
ID: string;
|
||||
Name: string;
|
||||
Session: string;
|
||||
Service: ConsulPreparedQueryService;
|
||||
CreateIndex: KvInt64;
|
||||
ModifyIndex: KvInt64;
|
||||
}
|
||||
export interface ConsulPreparedQueryInput {
|
||||
name: string;
|
||||
session: string;
|
||||
service: ConsulPreparedQueryService;
|
||||
}
|
||||
export interface ConsulPreparedQueryExecuteRequest {
|
||||
query: string;
|
||||
limit: number;
|
||||
connect: boolean;
|
||||
}
|
||||
export interface ConsulPreparedNodeIdentity {
|
||||
Node: string;
|
||||
Address: string;
|
||||
Datacenter: string;
|
||||
}
|
||||
export interface ConsulPreparedServiceIdentity {
|
||||
ID: string;
|
||||
Service: string;
|
||||
Address: string;
|
||||
Port: number;
|
||||
Tags: string[];
|
||||
}
|
||||
export interface ConsulPreparedQueryNode {
|
||||
Node: ConsulPreparedNodeIdentity;
|
||||
Service: ConsulPreparedServiceIdentity;
|
||||
}
|
||||
export interface ConsulPreparedQueryExecuteResponse {
|
||||
Service: string;
|
||||
Nodes: ConsulPreparedQueryNode[];
|
||||
DNS: { TTL: string };
|
||||
Datacenter: string;
|
||||
}
|
||||
export interface ConsulEventFireRequest {
|
||||
name: string;
|
||||
payloadBase64: string;
|
||||
nodeFilter: string;
|
||||
serviceFilter: string;
|
||||
tagFilter: string;
|
||||
}
|
||||
export interface ConsulEvent {
|
||||
ID: string;
|
||||
Name: string;
|
||||
Payload: string | null;
|
||||
NodeFilter: string;
|
||||
ServiceFilter: string;
|
||||
TagFilter: string;
|
||||
LTime: KvInt64;
|
||||
}
|
||||
export interface ConsulCoordinate {
|
||||
Node: string;
|
||||
Segment: string;
|
||||
Coord: { Vec: number[]; Error: number; Adjustment: number; Height: number };
|
||||
}
|
||||
|
||||
export type ConsulOperatorReadKind = "autopilot_configuration" | "autopilot_health" | "autopilot_state" | "raft_configuration" | "usage" | "license" | "audit";
|
||||
export interface ConsulOperatorDocument {
|
||||
kind: string;
|
||||
fields: Array<{ name: string; value: string }>;
|
||||
}
|
||||
export interface ConsulAutopilotUpdate {
|
||||
CleanupDeadServers?: boolean | null;
|
||||
LastContactThreshold?: string | null;
|
||||
MaxTrailingLogs?: number | null;
|
||||
MinQuorum?: number | null;
|
||||
ServerStabilizationTime?: string | null;
|
||||
RedundancyZoneTag?: string | null;
|
||||
DisableUpgradeMigration?: boolean | null;
|
||||
UpgradeVersionTag?: string | null;
|
||||
}
|
||||
export interface ConsulSnapshot {
|
||||
dataBase64: string;
|
||||
sizeBytes: number;
|
||||
datacenter: string;
|
||||
}
|
||||
export interface ConsulSnapshotRestoreRequest {
|
||||
snapshotBase64: string;
|
||||
targetDatacenter: string;
|
||||
confirmation: string;
|
||||
}
|
||||
export interface ConsulRaftWriteRequest {
|
||||
serverId: string | null;
|
||||
address: string | null;
|
||||
confirmation: string;
|
||||
}
|
||||
export interface ConsulKeyringWriteRequest {
|
||||
operation: "install" | "use" | "remove";
|
||||
key: string;
|
||||
confirmation: string;
|
||||
}
|
||||
export interface ConsulLicenseWriteRequest {
|
||||
license: string;
|
||||
confirmation: string;
|
||||
}
|
||||
|
||||
export interface ConsulDeletePrefixReport {
|
||||
items: ConsulDeleteResultItem[];
|
||||
succeeded: number;
|
||||
conflicted: number;
|
||||
failed: number;
|
||||
atomic: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulResponseMetadata {
|
||||
index: KvInt64 | null;
|
||||
filteredByAcls: boolean | null;
|
||||
knownLeader: boolean | null;
|
||||
lastContact: KvInt64 | null;
|
||||
queryBackend: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulListResponse<T> {
|
||||
items: T;
|
||||
metadata: ConsulResponseMetadata;
|
||||
}
|
||||
|
||||
export interface ConsulReadOptions {
|
||||
filter?: string | null;
|
||||
near?: string | null;
|
||||
index?: string | null;
|
||||
wait?: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulAgentIdentity {
|
||||
node: string;
|
||||
address: string;
|
||||
datacenter: string;
|
||||
version: string | null;
|
||||
server: boolean | null;
|
||||
revision: string | null;
|
||||
segment: string | null;
|
||||
}
|
||||
|
||||
export interface ConsulAgentMember {
|
||||
Name: string;
|
||||
Addr: string;
|
||||
Port: number;
|
||||
Tags: Record<string, string>;
|
||||
Status: number;
|
||||
}
|
||||
|
||||
export interface ConsulCatalogNode {
|
||||
ID: string;
|
||||
Node: string;
|
||||
Address: string;
|
||||
Datacenter: string;
|
||||
TaggedAddresses: Record<string, string>;
|
||||
NodeMeta: Record<string, string>;
|
||||
CreateIndex: number;
|
||||
ModifyIndex: number;
|
||||
}
|
||||
|
||||
export interface ConsulServiceAddress {
|
||||
Address: string;
|
||||
Port: number;
|
||||
}
|
||||
|
||||
export interface ConsulServiceWeights {
|
||||
Passing: number;
|
||||
Warning: number;
|
||||
}
|
||||
|
||||
export interface ConsulCatalogServiceNode {
|
||||
ID: string;
|
||||
Node: string;
|
||||
Address: string;
|
||||
Datacenter: string;
|
||||
TaggedAddresses: Record<string, string>;
|
||||
NodeMeta: Record<string, string>;
|
||||
ServiceKind: string;
|
||||
ServiceID: string;
|
||||
ServiceName: string;
|
||||
ServiceTags: string[];
|
||||
ServiceAddress: string;
|
||||
ServicePort: number;
|
||||
ServiceMeta: Record<string, string>;
|
||||
ServiceTaggedAddresses: Record<string, ConsulServiceAddress>;
|
||||
ServiceWeights: ConsulServiceWeights;
|
||||
CreateIndex: number;
|
||||
ModifyIndex: number;
|
||||
}
|
||||
|
||||
export interface ConsulCatalogService {
|
||||
ID: string;
|
||||
Service: string;
|
||||
Tags: string[];
|
||||
Address: string;
|
||||
TaggedAddresses: Record<string, ConsulServiceAddress>;
|
||||
Meta: Record<string, string>;
|
||||
Port: number;
|
||||
Weights: ConsulServiceWeights;
|
||||
}
|
||||
|
||||
export interface ConsulNodeServices {
|
||||
Node: ConsulCatalogNode;
|
||||
Services: Record<string, ConsulCatalogService>;
|
||||
}
|
||||
|
||||
export interface ConsulHealthCheckDefinition {
|
||||
HTTP: string;
|
||||
TCP: string;
|
||||
GRPC: string;
|
||||
Interval: string;
|
||||
Timeout: string;
|
||||
TTL: string;
|
||||
}
|
||||
|
||||
export interface ConsulHealthCheck {
|
||||
Node: string;
|
||||
CheckID: string;
|
||||
Name: string;
|
||||
Status: "passing" | "warning" | "critical" | string;
|
||||
Notes: string;
|
||||
Output: string;
|
||||
ServiceID: string;
|
||||
ServiceName: string;
|
||||
ServiceTags: string[];
|
||||
Type: string;
|
||||
ExposedPort: number;
|
||||
Definition: ConsulHealthCheckDefinition;
|
||||
CreateIndex: number;
|
||||
ModifyIndex: number;
|
||||
Maintenance: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulHealthService {
|
||||
ID: string;
|
||||
Service: string;
|
||||
Tags: string[];
|
||||
Address: string;
|
||||
TaggedAddresses: Record<string, ConsulServiceAddress>;
|
||||
Meta: Record<string, string>;
|
||||
Port: number;
|
||||
Weights: ConsulServiceWeights;
|
||||
Kind: string;
|
||||
}
|
||||
|
||||
export interface ConsulServiceInstance {
|
||||
Node: ConsulCatalogNode;
|
||||
Service: ConsulHealthService;
|
||||
Checks: ConsulHealthCheck[];
|
||||
}
|
||||
|
||||
export interface ConsulAgentService {
|
||||
Kind: string;
|
||||
ID: string;
|
||||
Service: string;
|
||||
Tags: string[];
|
||||
Meta: Record<string, string>;
|
||||
Port: number;
|
||||
Address: string;
|
||||
TaggedAddresses: Record<string, ConsulServiceAddress>;
|
||||
Weights: ConsulServiceWeights;
|
||||
EnableTagOverride: boolean;
|
||||
Datacenter: string;
|
||||
}
|
||||
|
||||
export interface ConsulAgentProxyRegistration {
|
||||
destinationServiceName: string;
|
||||
destinationServiceId: string;
|
||||
localServiceAddress: string;
|
||||
localServicePort: number;
|
||||
localServicePorts?: string[];
|
||||
localServiceSocketPath?: string;
|
||||
mode?: string;
|
||||
transparentProxy?: ConsulAgentTransparentProxy | null;
|
||||
config?: Record<string, unknown>;
|
||||
upstreams: ConsulAgentUpstream[];
|
||||
meshGateway?: ConsulAgentMeshGateway | null;
|
||||
expose?: ConsulAgentExpose | null;
|
||||
}
|
||||
|
||||
export interface ConsulAgentTransparentProxy {
|
||||
outboundListenerPort: number;
|
||||
dialedDirectly: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulAgentMeshGateway {
|
||||
mode: "" | "local" | "remote" | "none" | string;
|
||||
}
|
||||
|
||||
export interface ConsulAgentExposePath {
|
||||
path: string;
|
||||
protocol: "http" | "http2" | string;
|
||||
localPathPort: number;
|
||||
listenerPort: number;
|
||||
}
|
||||
|
||||
export interface ConsulAgentExpose {
|
||||
checks: boolean;
|
||||
paths: ConsulAgentExposePath[];
|
||||
}
|
||||
|
||||
export interface ConsulAgentUpstream {
|
||||
destinationType: "service" | "prepared_query" | string;
|
||||
destinationName: string;
|
||||
destinationPort?: number;
|
||||
destinationNamespace?: string;
|
||||
destinationPartition?: string;
|
||||
destinationPeer?: string;
|
||||
localBindAddress: string;
|
||||
localBindPort: number;
|
||||
localBindSocketPath?: string;
|
||||
localBindSocketMode?: string;
|
||||
datacenter: string;
|
||||
meshGateway?: ConsulAgentMeshGateway | null;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ConsulAgentServicePort {
|
||||
name: string;
|
||||
port: number;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface ConsulAgentConnectRegistration {
|
||||
native: boolean;
|
||||
sidecarService?: ConsulAgentServiceRegistration | null;
|
||||
}
|
||||
|
||||
export type ConsulAgentCheckDefinition =
|
||||
| { type: "http"; url: string; method: string; interval: string; timeout: string; tlsSkipVerify: boolean }
|
||||
| { type: "tcp"; address: string; interval: string; timeout: string }
|
||||
| { type: "grpc"; address: string; interval: string; timeout: string; tls: boolean }
|
||||
| { type: "ttl"; ttl: string }
|
||||
| { type: "docker"; containerId: string; shell: string; args: string[]; interval: string; timeout: string }
|
||||
| { type: "script"; args: string[]; interval: string; timeout: string };
|
||||
|
||||
export interface ConsulAgentCheckRegistration {
|
||||
id: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
serviceId: string;
|
||||
status: string;
|
||||
definition: ConsulAgentCheckDefinition;
|
||||
}
|
||||
|
||||
export interface ConsulAgentServiceRegistration {
|
||||
id: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
address: string;
|
||||
port: number;
|
||||
meta: Record<string, string>;
|
||||
weights: ConsulServiceWeights;
|
||||
kind: string;
|
||||
enableTagOverride: boolean;
|
||||
taggedAddresses?: Record<string, ConsulServiceAddress>;
|
||||
ports?: ConsulAgentServicePort[];
|
||||
proxy: ConsulAgentProxyRegistration | null;
|
||||
connect?: ConsulAgentConnectRegistration | null;
|
||||
checks: ConsulAgentCheckRegistration[];
|
||||
}
|
||||
|
||||
export interface ConsulAgentWriteResult {
|
||||
target: ConsulAgentIdentity;
|
||||
}
|
||||
|
||||
export type ConsulCheckStatus = "passing" | "warning" | "critical";
|
||||
|
||||
export interface ConsulSession {
|
||||
ID: string;
|
||||
Name: string;
|
||||
Node: string;
|
||||
LockDelay: number;
|
||||
Behavior: "release" | "delete" | string;
|
||||
TTL: string;
|
||||
NodeChecks: string[];
|
||||
ServiceChecks: ConsulSessionServiceCheck[];
|
||||
Namespace: string;
|
||||
Partition: string;
|
||||
CreateIndex: number;
|
||||
ModifyIndex: number;
|
||||
}
|
||||
|
||||
export interface ConsulSessionServiceCheck {
|
||||
ID: string;
|
||||
Namespace: string;
|
||||
}
|
||||
|
||||
export interface ConsulSessionCreateRequest {
|
||||
name: string;
|
||||
node: string;
|
||||
lockDelay?: string | null;
|
||||
behavior: "release" | "delete";
|
||||
ttl?: string | null;
|
||||
nodeChecks: string[];
|
||||
serviceChecks: ConsulSessionServiceCheck[];
|
||||
}
|
||||
|
||||
export interface ConsulSessionHeldKey {
|
||||
key: string;
|
||||
modifyIndex: KvInt64;
|
||||
}
|
||||
export interface ConsulSessionKeysResponse {
|
||||
items: ConsulKvRecord[];
|
||||
complete: boolean;
|
||||
filteredByAcls: boolean;
|
||||
}
|
||||
export interface ConsulSessionDestroyImpact {
|
||||
session: ConsulSession;
|
||||
heldKeys: ConsulSessionHeldKey[];
|
||||
complete: boolean;
|
||||
filteredByAcls: boolean;
|
||||
}
|
||||
export interface ConsulSessionDestroyRequest {
|
||||
id: string;
|
||||
expectedBehavior: string;
|
||||
expectedHeldKeys: ConsulSessionHeldKey[];
|
||||
}
|
||||
|
||||
export interface ConsulLockRequest {
|
||||
key: string;
|
||||
session: string;
|
||||
value: KvValue;
|
||||
flags?: KvInt64 | null;
|
||||
expectedModifyIndex?: KvInt64 | null;
|
||||
}
|
||||
|
||||
export interface ConsulLockResponse {
|
||||
acquired: boolean;
|
||||
key: string;
|
||||
session: string;
|
||||
}
|
||||
|
||||
export type ConsulAclKind = "token" | "policy" | "role" | "authMethod" | "bindingRule" | "templatedPolicy";
|
||||
export interface ConsulAclLink {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
}
|
||||
export interface ConsulAclToken {
|
||||
AccessorID?: string;
|
||||
SecretID?: string;
|
||||
Description?: string;
|
||||
Local?: boolean;
|
||||
AuthMethod?: string;
|
||||
Policies?: ConsulAclLink[];
|
||||
Roles?: ConsulAclLink[];
|
||||
ServiceIdentities?: unknown[];
|
||||
NodeIdentities?: unknown[];
|
||||
TemplatedPolicies?: unknown[];
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulAclPolicy {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
Rules?: string;
|
||||
Datacenters?: string[];
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulAclRole {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
Policies?: ConsulAclLink[];
|
||||
ServiceIdentities?: unknown[];
|
||||
NodeIdentities?: unknown[];
|
||||
TemplatedPolicies?: unknown[];
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulAclAuthMethod {
|
||||
Name?: string;
|
||||
Type?: string;
|
||||
DisplayName?: string;
|
||||
Description?: string;
|
||||
MaxTokenTTL?: string;
|
||||
TokenLocality?: string;
|
||||
Config?: unknown;
|
||||
}
|
||||
export interface ConsulAclBindingRule {
|
||||
ID?: string;
|
||||
Description?: string;
|
||||
AuthMethod?: string;
|
||||
Selector?: string;
|
||||
BindType?: string;
|
||||
BindName?: string;
|
||||
BindVars?: unknown;
|
||||
}
|
||||
export interface ConsulAclTemplatedPolicy {
|
||||
TemplateName?: string;
|
||||
Schema?: unknown;
|
||||
}
|
||||
export type ConsulAclList =
|
||||
| { kind: "token"; items: ConsulAclToken[] }
|
||||
| { kind: "policy"; items: ConsulAclPolicy[] }
|
||||
| { kind: "role"; items: ConsulAclRole[] }
|
||||
| { kind: "authMethod"; items: ConsulAclAuthMethod[] }
|
||||
| { kind: "bindingRule"; items: ConsulAclBindingRule[] }
|
||||
| { kind: "templatedPolicy"; items: ConsulAclTemplatedPolicy[] };
|
||||
export type ConsulAclItem =
|
||||
| { kind: "token"; item: ConsulAclToken }
|
||||
| { kind: "policy"; item: ConsulAclPolicy }
|
||||
| { kind: "role"; item: ConsulAclRole }
|
||||
| { kind: "authMethod"; item: ConsulAclAuthMethod }
|
||||
| { kind: "bindingRule"; item: ConsulAclBindingRule }
|
||||
| { kind: "templatedPolicy"; item: ConsulAclTemplatedPolicy };
|
||||
export type ConsulAclWrite = Exclude<ConsulAclItem, { kind: "templatedPolicy" }>;
|
||||
export interface ConsulAclReferences {
|
||||
tokenAccessorIds: string[];
|
||||
roleIds: string[];
|
||||
bindingRuleIds: string[];
|
||||
complete: boolean;
|
||||
filteredByAcls: boolean;
|
||||
}
|
||||
|
||||
export type ConsulEnterpriseKind = "namespace" | "partition";
|
||||
export interface ConsulNamespaceAcls {
|
||||
PolicyDefaults?: unknown[];
|
||||
RoleDefaults?: unknown[];
|
||||
}
|
||||
export interface ConsulNamespace {
|
||||
Name?: string;
|
||||
Partition?: string;
|
||||
Description?: string;
|
||||
Meta?: Record<string, string>;
|
||||
ACLs?: ConsulNamespaceAcls;
|
||||
DeletedAt?: string | null;
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulPartition {
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
Meta?: Record<string, string>;
|
||||
DeletedAt?: string | null;
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export type ConsulEnterpriseList = { kind: "namespace"; items: ConsulNamespace[] } | { kind: "partition"; items: ConsulPartition[] };
|
||||
export type ConsulEnterpriseItem = { kind: "namespace"; item: ConsulNamespace } | { kind: "partition"; item: ConsulPartition };
|
||||
export type ConsulEnterpriseWrite = ConsulEnterpriseItem;
|
||||
export interface ConsulScopeImpact {
|
||||
services: number;
|
||||
nodes: number;
|
||||
kvKeys: number;
|
||||
healthChecks: number;
|
||||
sessions: number;
|
||||
configEntries: number;
|
||||
intentions: number;
|
||||
peerings: number;
|
||||
namespaces: number;
|
||||
aclTokens: number;
|
||||
aclPolicies: number;
|
||||
aclRoles: number;
|
||||
aclAuthMethods: number;
|
||||
aclBindingRules: number;
|
||||
complete: boolean;
|
||||
filteredByAcls: boolean;
|
||||
unavailableResources: string[];
|
||||
}
|
||||
|
||||
export interface ConsulConfigEntry {
|
||||
kind: string;
|
||||
name: string;
|
||||
modifyIndex: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
export interface ConsulConfigEntryApply {
|
||||
kind: string;
|
||||
name: string;
|
||||
expectedModifyIndex: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
export interface ConsulIntention {
|
||||
ID?: string;
|
||||
Description?: string;
|
||||
SourceName?: string;
|
||||
DestinationName?: string;
|
||||
SourceNamespace?: string;
|
||||
DestinationNamespace?: string;
|
||||
SourcePartition?: string;
|
||||
DestinationPartition?: string;
|
||||
Action?: string;
|
||||
Permissions?: unknown[];
|
||||
Precedence?: number;
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulIntentionMatchRequest {
|
||||
by: "source" | "destination";
|
||||
name: string;
|
||||
}
|
||||
export interface ConsulIntentionExactRequest {
|
||||
source: string;
|
||||
destination: string;
|
||||
}
|
||||
export interface ConsulIntentionCheckRequest {
|
||||
source: string;
|
||||
destination: string;
|
||||
}
|
||||
export interface ConsulIntentionCheckResponse {
|
||||
Allowed: boolean;
|
||||
}
|
||||
export interface ConsulDiscoveryChain {
|
||||
ServiceName?: string;
|
||||
Namespace?: string;
|
||||
Partition?: string;
|
||||
Datacenter?: string;
|
||||
Protocol?: string;
|
||||
StartNode?: string;
|
||||
Nodes?: Record<string, unknown>;
|
||||
Targets?: Record<string, unknown>;
|
||||
}
|
||||
export interface ConsulPeering {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
Partition?: string;
|
||||
State?: string;
|
||||
PeerID?: string;
|
||||
PeerServerName?: string;
|
||||
PeerServerAddresses?: string[];
|
||||
ImportedServices?: string[];
|
||||
ExportedServices?: string[];
|
||||
CreateIndex?: number;
|
||||
ModifyIndex?: number;
|
||||
}
|
||||
export interface ConsulPeeringGenerateRequest {
|
||||
PeerName: string;
|
||||
Partition?: string;
|
||||
Meta?: unknown;
|
||||
}
|
||||
export interface ConsulPeeringToken {
|
||||
PeeringToken: string;
|
||||
}
|
||||
export interface ConsulPeeringEstablishRequest {
|
||||
PeerName: string;
|
||||
PeeringToken: string;
|
||||
Partition?: string;
|
||||
}
|
||||
export interface ConsulExportedService {
|
||||
Service?: string;
|
||||
Namespace?: string;
|
||||
Partition?: string;
|
||||
Consumers?: unknown[];
|
||||
}
|
||||
@@ -71,7 +71,8 @@ export type DatabaseType =
|
||||
| "jdbc"
|
||||
| "mq"
|
||||
| "mqtt"
|
||||
| "nacos";
|
||||
| "nacos"
|
||||
| "consul";
|
||||
|
||||
export function isElasticsearchCompatibleDatabaseType(dbType?: DatabaseType): boolean {
|
||||
return dbType === "elasticsearch" || dbType === "easysearch";
|
||||
@@ -841,6 +842,8 @@ export type TreeNodeType =
|
||||
| "etcd-dashboard"
|
||||
| "etcd-access-control"
|
||||
| "zookeeper-root"
|
||||
| "consul-root"
|
||||
| "consul-overview"
|
||||
| "mongo-db"
|
||||
| "mongo-gridfs"
|
||||
| "mongo-buckets"
|
||||
@@ -1045,6 +1048,8 @@ export interface QueryTab {
|
||||
| "etcd-dashboard"
|
||||
| "etcd-access-control"
|
||||
| "zookeeper"
|
||||
| "consul"
|
||||
| "consul-overview"
|
||||
| "mq"
|
||||
| "mqtt"
|
||||
| "nacos"
|
||||
|
||||
Reference in new issue
Block a user