feat(consul): add Consul full-stack workbench

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-10 16:25:44 +08:00
1 parent 20ddf2eca5
commit 37f13018d3
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");
});
});
@@ -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";');
});
});
@@ -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));