feat(consul): add Consul full-stack workbench
This commit is contained in:
148 files changed
+24032
-378
No files matched your search
@@ -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));
|
||||
|
||||
|
||||
Reference in new issue
Block a user