237 lines
14 KiB
Vue
237 lines
14 KiB
Vue
<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>
|