Files
dbx/apps/desktop/src/components/consul/ConsulTools.vue
T

371 lines
19 KiB
Vue

<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>