1025 lines
47 KiB
Vue
1025 lines
47 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, onDeactivated, ref, watch } from "vue";
|
|
import { ArrowRightLeft, Download, FileUp, Loader2, Search, X } from "@lucide/vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import KvKeyBrowser from "@/components/kv/KvKeyBrowser.vue";
|
|
import { Badge } from "@/components/ui/badge";
|
|
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 { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useToast } from "@/composables/useToast";
|
|
import * as api from "@/lib/backend/api";
|
|
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
|
import { kvExportFilenameStem, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import { useConsulStore } from "@/stores/consulStore";
|
|
import { isCurrentWatchEvent, nextWatchIndex } from "@/lib/consul/watchState";
|
|
import type { ConsulDeletePrefixPreview, ConsulDeletePrefixReport, ConsulImportConflictPolicy, ConsulImportPreview, ConsulImportReport, ConsulKvBundle, ConsulSearchMatch, ConsulScope, ConsulWatchEvent } from "@/types/consul";
|
|
|
|
const props = defineProps<{ connectionId: string }>();
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const connectionStore = useConnectionStore();
|
|
const consulStore = useConsulStore();
|
|
const browserRef = ref<InstanceType<typeof KvKeyBrowser> | null>(null);
|
|
const fileInputRef = ref<HTMLInputElement>();
|
|
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
|
|
const consulConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "consul"));
|
|
const transferTargetConnections = computed(() => (transferMode.value === "migration" ? consulConnections.value.filter((connection) => connection.id !== props.connectionId) : consulConnections.value));
|
|
|
|
const searchOpen = ref(false);
|
|
const searchQuery = ref("");
|
|
const searchPrefix = ref("");
|
|
const searchScope = ref<"all" | "key" | "value">("all");
|
|
const searchCaseSensitive = ref(false);
|
|
const searchRunning = ref(false);
|
|
const searchScanned = ref(0);
|
|
const searchMatched = ref(0);
|
|
const searchLimited = ref(false);
|
|
const searchFiltered = ref(false);
|
|
const searchResults = ref<ConsulSearchMatch[]>([]);
|
|
const searchError = ref("");
|
|
const exportingSearchResults = ref(false);
|
|
const searchHighlight = ref<{ key: string; query: string; caseSensitive: boolean; matchesKey: boolean; matchesValue: boolean } | null>(null);
|
|
let searchRequestId = "";
|
|
let searchConnectionId = "";
|
|
let searchScopeAtStart: ConsulScope = { datacenter: "", namespace: "", partition: "" };
|
|
let searchGeneration = 0;
|
|
let searchProgressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let searchProgressSequence = 0;
|
|
|
|
const exportOpen = ref(false);
|
|
const exportPrefix = ref("");
|
|
const exporting = ref(false);
|
|
|
|
const transferOpen = ref(false);
|
|
const transferMode = ref<"import" | "migration">("import");
|
|
const transferBundle = ref<ConsulKvBundle | null>(null);
|
|
const transferTargetId = ref("");
|
|
const transferSourcePrefix = ref("");
|
|
const transferPolicy = ref<ConsulImportConflictPolicy>("abort");
|
|
const transferPreview = ref<ConsulImportPreview | null>(null);
|
|
const transferReport = ref<ConsulImportReport | null>(null);
|
|
const transferLoading = ref(false);
|
|
const transferApplying = ref(false);
|
|
const transferError = ref("");
|
|
const transferTargetReadOnly = computed(() => Boolean(connectionStore.getConfig(transferTargetId.value)?.read_only));
|
|
|
|
const deleteOpen = ref(false);
|
|
const deletePrefix = ref("");
|
|
const deletePreview = ref<ConsulDeletePrefixPreview | null>(null);
|
|
const deleteReport = ref<ConsulDeletePrefixReport | null>(null);
|
|
const deleteLoading = ref(false);
|
|
const deleteExecuting = ref(false);
|
|
const deleteError = ref("");
|
|
type PrefixSuggestionTarget = "export" | "migration" | "delete";
|
|
const prefixSuggestionTarget = ref<PrefixSuggestionTarget | null>(null);
|
|
const prefixSuggestions = ref<Array<{ key: string }>>([]);
|
|
const prefixSuggestionIndex = ref(-1);
|
|
const prefixSuggestionDebounceMs = 300;
|
|
let prefixSuggestionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let prefixSuggestionRequestId = 0;
|
|
const watchPrefix = ref("");
|
|
const watchMode = ref<"key" | "prefix">("prefix");
|
|
const watchRunning = ref(false);
|
|
const watchIndex = ref<string | null>(null);
|
|
const watchError = ref("");
|
|
let watchOperationId = "";
|
|
let watchUnlisten: (() => void) | null = null;
|
|
let watchConnectionId = "";
|
|
let watchScope: ConsulScope = { datacenter: "", namespace: "", partition: "" };
|
|
let watchGeneration = 0;
|
|
|
|
function currentScope() {
|
|
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 || ""),
|
|
namespace: String(config.namespace || config.consulNamespace || config.consul_namespace || ""),
|
|
partition: String(config.partition || config.consulPartition || config.consul_partition || ""),
|
|
};
|
|
}
|
|
|
|
async function startWatch() {
|
|
if (watchRunning.value) return;
|
|
watchError.value = "";
|
|
watchIndex.value = null;
|
|
watchConnectionId = props.connectionId;
|
|
watchScope = currentScope();
|
|
consulStore.bindConnection(watchConnectionId, watchScope);
|
|
watchGeneration = consulStore.generation;
|
|
watchOperationId = globalThis.crypto?.randomUUID?.() || `watch-${Date.now()}`;
|
|
consulStore.registerOperation(watchOperationId);
|
|
watchRunning.value = true;
|
|
if (isTauriRuntime()) {
|
|
const { listen } = await import("@tauri-apps/api/event");
|
|
try {
|
|
watchUnlisten = await listen<ConsulWatchEvent>("consul-watch", (event) => {
|
|
const payload = event.payload;
|
|
if (!isCurrentWatchEvent({ connectionId: watchConnectionId, operationId: watchOperationId, generation: watchGeneration }, payload)) return;
|
|
if (payload.error) {
|
|
if (watchRunning.value && !payload.error.includes("CANCELLED")) toast(payload.error, 5000);
|
|
void stopWatch();
|
|
return;
|
|
}
|
|
if (payload.result) applyWatchResult(payload.result);
|
|
if (watchRunning.value) void startTauriWatchRequest();
|
|
});
|
|
await startTauriWatchRequest();
|
|
} catch (error) {
|
|
if (watchRunning.value) toast(error instanceof Error ? error.message : String(error), 5000);
|
|
await stopWatch();
|
|
}
|
|
return;
|
|
}
|
|
while (watchRunning.value) {
|
|
try {
|
|
const result = await api.consulBlockingQuery(watchConnectionId, {
|
|
operationId: watchOperationId,
|
|
generation: watchGeneration,
|
|
key: watchPrefix.value,
|
|
prefix: watchMode.value === "prefix",
|
|
index: watchIndex.value,
|
|
waitSeconds: 300,
|
|
});
|
|
applyWatchResult(result);
|
|
} catch (error) {
|
|
if (watchRunning.value && !String(error).includes("CANCELLED")) {
|
|
watchError.value = error instanceof Error ? error.message : String(error);
|
|
toast(watchError.value, 5000);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
watchRunning.value = false;
|
|
consulStore.completeOperation(watchOperationId);
|
|
}
|
|
|
|
function watchRequest() {
|
|
return {
|
|
operationId: watchOperationId,
|
|
generation: watchGeneration,
|
|
key: watchPrefix.value,
|
|
prefix: watchMode.value === "prefix",
|
|
index: watchIndex.value,
|
|
waitSeconds: 300,
|
|
};
|
|
}
|
|
|
|
async function watchSelectedKey(route: { key: string }) {
|
|
if (watchRunning.value && watchMode.value === "key" && watchPrefix.value === route.key) {
|
|
await stopWatch();
|
|
return;
|
|
}
|
|
if (watchRunning.value) await stopWatch();
|
|
watchMode.value = "key";
|
|
watchPrefix.value = route.key;
|
|
await startWatch();
|
|
}
|
|
|
|
async function startTauriWatchRequest() {
|
|
await api.consulWatchStart(watchConnectionId, watchRequest());
|
|
}
|
|
|
|
function applyWatchResult(result: import("@/types/consul").ConsulBlockingResponse) {
|
|
const next = nextWatchIndex(watchIndex.value, result.metadata.index);
|
|
watchIndex.value = next.index;
|
|
if (result.changed) browserRef.value?.refresh();
|
|
}
|
|
|
|
async function stopWatch() {
|
|
if (!watchRunning.value) return;
|
|
watchRunning.value = false;
|
|
await api.consulCancelBlocking(watchConnectionId, watchScope, watchGeneration, watchOperationId).catch(() => false);
|
|
watchUnlisten?.();
|
|
watchUnlisten = null;
|
|
consulStore.completeOperation(watchOperationId);
|
|
}
|
|
|
|
const consulApi = {
|
|
listPrefix: api.consulListPrefix,
|
|
get: api.consulGet,
|
|
put: api.consulPut,
|
|
deleteKey: api.consulDelete,
|
|
rename: renameConsulKey,
|
|
copy: copyConsulKey,
|
|
exportScope: exportConsulScope,
|
|
};
|
|
|
|
async function mutateConsulKey(connectionId: string, request: { key: string; newKey: string; expectedModRevision?: api.KvInt64 | null }, copy: boolean) {
|
|
if (!request.expectedModRevision) throw new Error(`CONSUL_CAS_REQUIRED: ${t("consul.ui.sourceKeyMissingModifyIndex")}`);
|
|
const result = await api.consulRenameKey(connectionId, request.key, request.newKey, request.expectedModRevision, copy);
|
|
if (!result.committed) {
|
|
const detail = result.errors.map((error) => `[${error.opIndex}] ${error.message}`).join("; ");
|
|
throw new Error(`CONSUL_TXN_CONFLICT: ${detail || t("consul.ui.transactionRejected")}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function renameConsulKey(connectionId: string, request: { key: string; newKey: string; expectedModRevision?: api.KvInt64 | null }) {
|
|
await mutateConsulKey(connectionId, request, false);
|
|
return { renamed: true };
|
|
}
|
|
|
|
async function copyConsulKey(connectionId: string, request: { key: string; newKey: string; expectedModRevision?: api.KvInt64 | null }) {
|
|
await mutateConsulKey(connectionId, request, true);
|
|
return { copied: true };
|
|
}
|
|
|
|
const labels = computed(() => ({
|
|
prefixPlaceholder: t("consul.prefixPlaceholder"),
|
|
newKey: t("consul.newKey"),
|
|
loadingKeys: t("consul.loadingKeys"),
|
|
empty: t("consul.empty"),
|
|
loadMore: t("consul.loadMore"),
|
|
selectKey: t("consul.selectKey"),
|
|
loadingValue: t("consul.loadingValue"),
|
|
notFound: t("consul.notFound"),
|
|
edit: t("consul.edit"),
|
|
editKey: t("consul.editKey"),
|
|
delete: t("consul.delete"),
|
|
deleteTitle: t("consul.deleteTitle"),
|
|
keyLabel: t("consul.keyLabel"),
|
|
keyPlaceholder: t("consul.keyPlaceholder"),
|
|
keyRequired: t("consul.keyRequired"),
|
|
rootReadonly: t("consul.rootReadonly"),
|
|
saved: t("consul.saved"),
|
|
deleted: t("consul.deleted"),
|
|
base64Readonly: t("consul.base64Readonly"),
|
|
rename: t("consul.rename"),
|
|
clone: t("consul.clone"),
|
|
copyKey: t("consul.copyKey"),
|
|
export: t("consul.export"),
|
|
value: t("consul.value"),
|
|
valueContent: t("consul.valueContent"),
|
|
format: t("consul.format"),
|
|
metadata: t("consul.metadata"),
|
|
prettyJson: t("consul.prettyJson"),
|
|
invalidJson: t("consul.invalidJson"),
|
|
summarySize: t("consul.summarySize"),
|
|
conflict: t("consul.conflict"),
|
|
keyAlreadyExists: t("consul.keyAlreadyExists"),
|
|
aclFiltered: t("consul.aclFiltered"),
|
|
locked: t("consul.locked"),
|
|
sessionProtected: t("consul.ui.sessionProtected"),
|
|
sessionProtectedHint: t("consul.ui.sessionProtectedHint"),
|
|
sessionId: t("consul.ui.sessionId"),
|
|
copy: t("consul.ui.copy"),
|
|
copied: t("consul.ui.copied"),
|
|
copyFailed: t("consul.ui.copyFailed"),
|
|
valueTooLarge: t("consul.valueTooLarge"),
|
|
deletePrefix: t("consul.tools.deletePrefix"),
|
|
watch: t("consul.watchKey"),
|
|
stopWatching: t("consul.stopWatching"),
|
|
}));
|
|
|
|
async function exportConsulScope(connectionId: string, request: KvExportScopeRequest) {
|
|
exporting.value = true;
|
|
try {
|
|
const bundle = await api.consulExportBundle(connectionId, { path: request.path, kind: request.kind });
|
|
const filename = `dbx-consul-${kvExportFilenameStem(request.path)}-${Date.now()}.json`;
|
|
const saved = await saveBundle(bundle, filename);
|
|
if (saved) toast(t("consul.tools.exported", { count: bundle.entries.length }), 2500);
|
|
} catch (error) {
|
|
toast(error instanceof Error ? error.message : String(error), 5000);
|
|
} finally {
|
|
exporting.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveJsonFile(payload: unknown, filename: string, fileType: string): Promise<boolean> {
|
|
const content = JSON.stringify(payload, null, 2);
|
|
if (isTauriRuntime()) {
|
|
const [{ save }, { writeTextFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
|
const path = await save({ defaultPath: filename, filters: [{ name: fileType, extensions: ["json"] }] });
|
|
if (!path) return false;
|
|
await writeTextFile(path, content);
|
|
return true;
|
|
}
|
|
const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
anchor.click();
|
|
URL.revokeObjectURL(url);
|
|
return true;
|
|
}
|
|
|
|
async function saveBundle(bundle: ConsulKvBundle, filename: string): Promise<boolean> {
|
|
return saveJsonFile(bundle, filename, t("consul.ui.bundleFileType"));
|
|
}
|
|
|
|
async function exportPrefixBundle() {
|
|
await exportConsulScope(props.connectionId, { path: exportPrefix.value, kind: "prefix" });
|
|
exportOpen.value = false;
|
|
}
|
|
|
|
function openSearch() {
|
|
searchOpen.value = true;
|
|
searchError.value = "";
|
|
}
|
|
|
|
async function runSearch() {
|
|
const query = searchQuery.value.trim();
|
|
if (!query || searchRunning.value) return;
|
|
consulStore.bindConnection(props.connectionId, currentScope());
|
|
searchConnectionId = props.connectionId;
|
|
searchScopeAtStart = currentScope();
|
|
searchGeneration = consulStore.generation;
|
|
searchRequestId = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`;
|
|
searchRunning.value = true;
|
|
searchScanned.value = 0;
|
|
searchMatched.value = 0;
|
|
searchLimited.value = false;
|
|
searchFiltered.value = false;
|
|
searchResults.value = [];
|
|
searchError.value = "";
|
|
startSearchProgress();
|
|
try {
|
|
const result = await api.consulSearch(props.connectionId, {
|
|
requestId: searchRequestId,
|
|
prefix: searchPrefix.value,
|
|
query,
|
|
searchKeys: searchScope.value !== "value",
|
|
searchValues: searchScope.value !== "key",
|
|
caseSensitive: searchCaseSensitive.value,
|
|
limit: 1000,
|
|
maxScan: 10_000,
|
|
generation: searchGeneration,
|
|
});
|
|
if (props.connectionId !== searchConnectionId || consulStore.generation !== searchGeneration) return;
|
|
searchResults.value = result.matches;
|
|
searchScanned.value = result.scanned;
|
|
searchMatched.value = result.matched;
|
|
searchLimited.value = result.limited;
|
|
searchFiltered.value = Boolean(result.filteredByAcls);
|
|
} catch (error) {
|
|
if (props.connectionId !== searchConnectionId || consulStore.generation !== searchGeneration) return;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (!message.includes("CONSUL_SEARCH_CANCELLED")) searchError.value = message;
|
|
} finally {
|
|
stopSearchProgress();
|
|
searchRunning.value = false;
|
|
}
|
|
}
|
|
|
|
async function exportSearchResults() {
|
|
if (!searchResults.value.length || exportingSearchResults.value) return;
|
|
exportingSearchResults.value = true;
|
|
try {
|
|
const report = {
|
|
format: "dbx-consul-kv-search-results",
|
|
version: 1,
|
|
exportedAtUnixMs: Date.now(),
|
|
source: currentScope(),
|
|
search: {
|
|
query: searchQuery.value.trim(),
|
|
prefix: searchPrefix.value.trim(),
|
|
scope: searchScope.value,
|
|
caseSensitive: searchCaseSensitive.value,
|
|
scanned: searchScanned.value,
|
|
matched: searchMatched.value,
|
|
limited: searchLimited.value,
|
|
filteredByAcls: searchFiltered.value,
|
|
},
|
|
results: searchResults.value,
|
|
};
|
|
const saved = await saveJsonFile(report, `dbx-consul-search-${Date.now()}.json`, t("consul.tools.searchResultsFileType"));
|
|
if (saved) toast(t("consul.tools.searchResultsExported", { count: searchResults.value.length }), 2500);
|
|
} catch (error) {
|
|
toast(error instanceof Error ? error.message : String(error), 5000);
|
|
} finally {
|
|
exportingSearchResults.value = false;
|
|
}
|
|
}
|
|
|
|
function startSearchProgress() {
|
|
stopSearchProgress();
|
|
const sequence = ++searchProgressSequence;
|
|
const poll = async () => {
|
|
if (sequence !== searchProgressSequence || !searchRequestId) {
|
|
searchProgressTimer = null;
|
|
return;
|
|
}
|
|
try {
|
|
const progress = await api.consulSearchProgress(searchConnectionId, searchRequestId, searchScopeAtStart, searchGeneration);
|
|
if (progress.running) searchScanned.value = progress.scanned;
|
|
} catch {
|
|
// Progress is best-effort; the search result carries the authoritative count.
|
|
}
|
|
if (sequence === searchProgressSequence && searchRequestId)
|
|
searchProgressTimer = setTimeout(() => {
|
|
void poll();
|
|
}, 250);
|
|
else searchProgressTimer = null;
|
|
};
|
|
searchProgressTimer = setTimeout(() => {
|
|
void poll();
|
|
}, 250);
|
|
}
|
|
|
|
function stopSearchProgress() {
|
|
searchProgressSequence += 1;
|
|
if (searchProgressTimer) clearTimeout(searchProgressTimer);
|
|
searchProgressTimer = null;
|
|
}
|
|
|
|
async function cancelSearch() {
|
|
if (!searchRequestId) return;
|
|
await api.consulCancelSearch(searchConnectionId, searchRequestId, searchScopeAtStart, searchGeneration);
|
|
}
|
|
|
|
async function openSearchResult(result: ConsulSearchMatch) {
|
|
searchHighlight.value = {
|
|
key: result.key,
|
|
query: searchQuery.value.trim(),
|
|
caseSensitive: searchCaseSensitive.value,
|
|
matchesKey: result.matchesKey,
|
|
matchesValue: result.matchesValue,
|
|
};
|
|
searchOpen.value = false;
|
|
await nextTick();
|
|
await browserRef.value?.selectKey(result.key);
|
|
}
|
|
|
|
function validateBundle(value: unknown): ConsulKvBundle {
|
|
const bundle = value as Partial<ConsulKvBundle>;
|
|
if (bundle.format !== "dbx-consul-kv-bundle" || bundle.version !== 1 || !Array.isArray(bundle.entries)) {
|
|
throw new Error(t("consul.tools.invalidBundle"));
|
|
}
|
|
return bundle as ConsulKvBundle;
|
|
}
|
|
|
|
async function chooseImportFile() {
|
|
if (isTauriRuntime()) {
|
|
const [{ open }, { readTextFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
|
const path = await open({ multiple: false, filters: [{ name: t("consul.ui.bundleFileType"), extensions: ["json"] }] });
|
|
if (!path || Array.isArray(path)) return;
|
|
await loadImportText(await readTextFile(path));
|
|
return;
|
|
}
|
|
fileInputRef.value?.click();
|
|
}
|
|
|
|
async function onImportFile(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
input.value = "";
|
|
if (file) await loadImportText(await file.text());
|
|
}
|
|
|
|
async function loadImportText(text: string) {
|
|
try {
|
|
transferMode.value = "import";
|
|
transferBundle.value = validateBundle(JSON.parse(text));
|
|
transferTargetId.value = props.connectionId;
|
|
transferPolicy.value = "abort";
|
|
transferPreview.value = null;
|
|
transferReport.value = null;
|
|
transferError.value = "";
|
|
transferOpen.value = true;
|
|
await previewTransfer();
|
|
} catch (error) {
|
|
toast(error instanceof Error ? error.message : String(error), 5000);
|
|
}
|
|
}
|
|
|
|
function openMigration() {
|
|
closePrefixSuggestions();
|
|
transferMode.value = "migration";
|
|
transferBundle.value = null;
|
|
transferTargetId.value = consulConnections.value.find((connection) => connection.id !== props.connectionId)?.id || "";
|
|
transferSourcePrefix.value = "";
|
|
transferPolicy.value = "abort";
|
|
transferPreview.value = null;
|
|
transferReport.value = null;
|
|
transferError.value = "";
|
|
transferOpen.value = true;
|
|
}
|
|
|
|
async function loadMigrationPreview() {
|
|
if (!transferTargetId.value) return;
|
|
transferLoading.value = true;
|
|
transferError.value = "";
|
|
try {
|
|
transferBundle.value = await api.consulExportBundle(props.connectionId, { path: transferSourcePrefix.value, kind: "prefix" });
|
|
await previewTransfer();
|
|
} catch (error) {
|
|
transferError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
transferLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function previewTransfer() {
|
|
if (!transferBundle.value || !transferTargetId.value) return;
|
|
transferLoading.value = true;
|
|
transferPreview.value = null;
|
|
transferReport.value = null;
|
|
transferError.value = "";
|
|
try {
|
|
transferPreview.value = await api.consulImportPreview(transferTargetId.value, {
|
|
bundle: transferBundle.value,
|
|
policy: transferPolicy.value,
|
|
});
|
|
} catch (error) {
|
|
transferError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
transferLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function executeTransfer() {
|
|
if (!transferBundle.value || !transferTargetId.value || !transferPreview.value?.canApply) return;
|
|
transferApplying.value = true;
|
|
transferError.value = "";
|
|
try {
|
|
transferReport.value = await api.consulImportExecute(transferTargetId.value, {
|
|
bundle: transferBundle.value,
|
|
policy: transferPolicy.value,
|
|
previewId: transferPreview.value.previewId,
|
|
});
|
|
toast(t("consul.tools.importApplied", { count: transferReport.value.succeeded }), 3000);
|
|
if (transferTargetId.value === props.connectionId) browserRef.value?.refresh();
|
|
} catch (error) {
|
|
transferError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
transferApplying.value = false;
|
|
}
|
|
}
|
|
|
|
function resetTransferPreview() {
|
|
transferPreview.value = null;
|
|
transferReport.value = null;
|
|
if (transferMode.value === "import") void previewTransfer();
|
|
}
|
|
|
|
function openDeletePrefix(prefix = "") {
|
|
closePrefixSuggestions();
|
|
deletePrefix.value = prefix;
|
|
deletePreview.value = null;
|
|
deleteReport.value = null;
|
|
deleteError.value = "";
|
|
deleteOpen.value = true;
|
|
}
|
|
|
|
function closePrefixSuggestions() {
|
|
prefixSuggestionRequestId++;
|
|
if (prefixSuggestionTimer) {
|
|
clearTimeout(prefixSuggestionTimer);
|
|
prefixSuggestionTimer = null;
|
|
}
|
|
prefixSuggestionTarget.value = null;
|
|
prefixSuggestionIndex.value = -1;
|
|
prefixSuggestions.value = [];
|
|
}
|
|
|
|
function prefixValue(target: PrefixSuggestionTarget): string {
|
|
if (target === "export") return exportPrefix.value;
|
|
if (target === "migration") return transferSourcePrefix.value;
|
|
return deletePrefix.value;
|
|
}
|
|
|
|
function setPrefixValue(target: PrefixSuggestionTarget, value: string) {
|
|
if (target === "export") exportPrefix.value = value;
|
|
else if (target === "migration") transferSourcePrefix.value = value;
|
|
else deletePrefix.value = value;
|
|
}
|
|
|
|
function schedulePrefixSuggestions(target: PrefixSuggestionTarget, value: string) {
|
|
closePrefixSuggestions();
|
|
const query = value.trim();
|
|
if (!query) {
|
|
prefixSuggestions.value = [];
|
|
return;
|
|
}
|
|
|
|
const requestId = ++prefixSuggestionRequestId;
|
|
const connectionId = props.connectionId;
|
|
prefixSuggestionTarget.value = target;
|
|
prefixSuggestionTimer = setTimeout(() => {
|
|
void api
|
|
.consulListPrefix(connectionId, query, 8, null)
|
|
.then((result) => {
|
|
if (requestId !== prefixSuggestionRequestId || connectionId !== props.connectionId || prefixValue(target).trim() !== query) return;
|
|
prefixSuggestions.value = result.keys;
|
|
})
|
|
.catch(() => {
|
|
if (requestId === prefixSuggestionRequestId) prefixSuggestions.value = [];
|
|
});
|
|
}, prefixSuggestionDebounceMs);
|
|
}
|
|
|
|
function acceptPrefixSuggestion(target: PrefixSuggestionTarget, index: number) {
|
|
const suggestion = prefixSuggestions.value[index];
|
|
if (!suggestion) return;
|
|
setPrefixValue(target, suggestion.key);
|
|
// A directory suggestion ends with `/`. Immediately query that accepted
|
|
// prefix so the user can continue completing the next path segment without
|
|
// deleting and retyping the slash.
|
|
schedulePrefixSuggestions(target, suggestion.key);
|
|
}
|
|
|
|
function movePrefixSuggestion(target: PrefixSuggestionTarget, delta: number) {
|
|
if (prefixSuggestionTarget.value !== target || !prefixSuggestions.value.length) return;
|
|
prefixSuggestionIndex.value = (prefixSuggestionIndex.value + delta + prefixSuggestions.value.length) % prefixSuggestions.value.length;
|
|
}
|
|
|
|
function onPrefixSuggestionKeydown(target: PrefixSuggestionTarget, event: KeyboardEvent) {
|
|
if (event.isComposing) return;
|
|
if (event.key === "Escape") {
|
|
closePrefixSuggestions();
|
|
return;
|
|
}
|
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
if (prefixSuggestionTarget.value !== target || !prefixSuggestions.value.length) return;
|
|
event.preventDefault();
|
|
movePrefixSuggestion(target, event.key === "ArrowDown" ? 1 : -1);
|
|
return;
|
|
}
|
|
if (event.key !== "Enter" && event.key !== "Tab") return;
|
|
if (prefixSuggestionTarget.value === target && prefixSuggestions.value.length > 0) {
|
|
event.preventDefault();
|
|
acceptPrefixSuggestion(target, prefixSuggestionIndex.value >= 0 ? prefixSuggestionIndex.value : 0);
|
|
return;
|
|
}
|
|
// With no active completion, Tab keeps its native focus-navigation behavior.
|
|
if (event.key === "Tab") return;
|
|
if (target === "delete" && deletePrefix.value.trim() && !deletePreview.value) {
|
|
event.preventDefault();
|
|
void previewDeletePrefix();
|
|
}
|
|
}
|
|
|
|
async function previewDeletePrefix() {
|
|
if (!deletePrefix.value.trim()) return;
|
|
deleteLoading.value = true;
|
|
deletePreview.value = null;
|
|
deleteReport.value = null;
|
|
deleteError.value = "";
|
|
try {
|
|
deletePreview.value = await api.consulDeletePrefixPreview(props.connectionId, deletePrefix.value.trim());
|
|
} catch (error) {
|
|
deleteError.value = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
deleteLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function executeDeletePrefix() {
|
|
if (!deletePreview.value?.canExecute) return;
|
|
deleteExecuting.value = true;
|
|
deleteError.value = "";
|
|
try {
|
|
deleteReport.value = await api.consulDeletePrefixExecute(props.connectionId, {
|
|
prefix: deletePreview.value.prefix,
|
|
expected: deletePreview.value.candidates,
|
|
});
|
|
toast(t("consul.tools.prefixDeleted", { count: deleteReport.value.succeeded }), 3000);
|
|
browserRef.value?.refresh();
|
|
} catch (error) {
|
|
deleteError.value = error instanceof Error ? error.message : String(error);
|
|
deletePreview.value = null;
|
|
} finally {
|
|
deleteExecuting.value = false;
|
|
}
|
|
}
|
|
|
|
function operationVariant(operation: string): "default" | "secondary" | "destructive" | "outline" {
|
|
if (operation === "create" || operation === "succeeded") return "secondary";
|
|
if (operation === "conflict" || operation === "locked" || operation === "failed" || operation === "conflicted") return "destructive";
|
|
return "outline";
|
|
}
|
|
|
|
function reportBatchCount(items: Array<{ batch: number | null }>): number {
|
|
return items.reduce((count, item) => Math.max(count, item.batch ?? 0), 0);
|
|
}
|
|
|
|
function focusSearch(): boolean {
|
|
return browserRef.value?.focusSearch() ?? false;
|
|
}
|
|
|
|
function refresh(): boolean {
|
|
return browserRef.value?.refresh() ?? false;
|
|
}
|
|
|
|
watch(
|
|
() => `${props.connectionId}\u0000${currentScope().datacenter}\u0000${currentScope().partition}\u0000${currentScope().namespace}`,
|
|
() => {
|
|
void stopWatch();
|
|
watchIndex.value = null;
|
|
if (searchRunning.value && searchRequestId) void cancelSearch();
|
|
stopSearchProgress();
|
|
},
|
|
);
|
|
|
|
function stopBackgroundWork() {
|
|
void stopWatch();
|
|
if (searchRunning.value && searchRequestId) void cancelSearch();
|
|
stopSearchProgress();
|
|
}
|
|
|
|
onDeactivated(stopBackgroundWork);
|
|
onBeforeUnmount(() => {
|
|
closePrefixSuggestions();
|
|
stopBackgroundWork();
|
|
});
|
|
|
|
defineExpose({ focusSearch, refresh });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full min-h-0 flex-col bg-background">
|
|
<input ref="fileInputRef" type="file" accept="application/json,.json" class="hidden" @change="onImportFile" />
|
|
<KvKeyBrowser
|
|
ref="browserRef"
|
|
class="min-h-0 flex-1"
|
|
:connection-id="props.connectionId"
|
|
:api="consulApi"
|
|
:labels="labels"
|
|
metadata-style="consul"
|
|
lazy-hierarchy
|
|
lazy-path-style="relative"
|
|
safe-write
|
|
allow-binary-edit
|
|
enable-node-actions
|
|
supports-flags
|
|
:read-only="readOnly"
|
|
:max-value-bytes="512 * 1024"
|
|
:on-watch-key="watchSelectedKey"
|
|
:on-delete-prefix="openDeletePrefix"
|
|
:watch-active-key="watchRunning && watchMode === 'key' ? watchPrefix : null"
|
|
:search-highlight="searchHighlight"
|
|
export-format="dbx-consul-kv-bundle"
|
|
export-file-extension=".dbx-consul.json"
|
|
export-fallback-name="consul-key"
|
|
>
|
|
<template #toolbar-trailing>
|
|
<Button variant="ghost" size="sm" class="h-9 gap-1.5 whitespace-nowrap" @click="openSearch"> <Search class="h-3.5 w-3.5" />{{ t("consul.tools.search") }} </Button>
|
|
<Button variant="ghost" size="sm" class="h-9 gap-1.5 whitespace-nowrap" :disabled="exporting" @click="exportOpen = true"> <Download class="h-3.5 w-3.5" />{{ t("consul.tools.exportPrefix") }} </Button>
|
|
<Button variant="ghost" size="sm" class="h-9 gap-1.5 whitespace-nowrap" :disabled="readOnly" @click="chooseImportFile"> <FileUp class="h-3.5 w-3.5" />{{ t("consul.tools.import") }} </Button>
|
|
<Button variant="ghost" size="sm" class="h-9 gap-1.5 whitespace-nowrap" @click="openMigration"> <ArrowRightLeft class="h-3.5 w-3.5" />{{ t("consul.tools.migrate") }} </Button>
|
|
</template>
|
|
</KvKeyBrowser>
|
|
|
|
<Dialog v-model:open="searchOpen">
|
|
<DialogContent class="flex max-h-[82vh] flex-col sm:max-w-4xl">
|
|
<DialogHeader
|
|
><DialogTitle>{{ t("consul.tools.searchTitle") }}</DialogTitle></DialogHeader
|
|
>
|
|
<div class="grid shrink-0 gap-3 sm:grid-cols-[1fr_1fr_160px_auto]">
|
|
<Input v-model="searchQuery" :placeholder="t('consul.tools.searchQuery')" @keydown.enter="runSearch" />
|
|
<Input v-model="searchPrefix" :placeholder="t('consul.tools.prefixOptional')" @keydown.enter="runSearch" />
|
|
<Select v-model="searchScope">
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{{ t("consul.tools.searchAll") }}</SelectItem>
|
|
<SelectItem value="key">{{ t("consul.tools.searchKeys") }}</SelectItem>
|
|
<SelectItem value="value">{{ t("consul.tools.searchValues") }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button :disabled="searchRunning || !searchQuery.trim()" @click="runSearch"> <Loader2 v-if="searchRunning" class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.tools.search") }} </Button>
|
|
</div>
|
|
<label class="flex items-center gap-2 text-xs text-muted-foreground"> <input v-model="searchCaseSensitive" type="checkbox" class="h-4 w-4" />{{ t("consul.tools.caseSensitive") }} </label>
|
|
<div v-if="searchRunning" class="flex items-center justify-between border-y py-2 text-xs text-muted-foreground">
|
|
<span>{{ t("consul.tools.scanned", { count: searchScanned }) }}</span>
|
|
<Button variant="ghost" size="sm" class="h-7 gap-1.5" @click="cancelSearch"><X class="h-3.5 w-3.5" />{{ t("common.cancel") }}</Button>
|
|
</div>
|
|
<div v-if="searchError" class="border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ searchError }}</div>
|
|
<div v-if="searchFiltered" class="border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">{{ t("consul.aclFiltered") }}</div>
|
|
<div v-if="searchLimited" class="text-xs text-amber-700 dark:text-amber-300">{{ t("consul.tools.searchLimited") }}</div>
|
|
<div class="min-h-0 flex-1 overflow-auto border">
|
|
<button v-for="result in searchResults" :key="`${result.key}:${result.modifyIndex}`" class="flex w-full items-center gap-3 border-b px-3 py-2 text-left hover:bg-muted/50" @click="openSearchResult(result)">
|
|
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ result.key }}</span>
|
|
<Badge v-if="result.matchesKey" variant="outline">{{ t("consul.ui.key") }}</Badge>
|
|
<Badge v-if="result.matchesValue" variant="outline">{{ t("consul.ui.value") }}</Badge>
|
|
</button>
|
|
<div v-if="!searchRunning && !searchResults.length" class="flex h-24 items-center justify-center text-sm text-muted-foreground">{{ t("consul.tools.noSearchResults") }}</div>
|
|
</div>
|
|
<div class="flex shrink-0 items-center justify-between gap-3 text-xs text-muted-foreground">
|
|
<span>{{ t("consul.tools.searchSummary", { matched: searchMatched, scanned: searchScanned }) }}</span>
|
|
<Button v-if="searchResults.length" size="sm" variant="outline" class="h-8 gap-1.5" :disabled="exportingSearchResults" @click="exportSearchResults">
|
|
<Loader2 v-if="exportingSearchResults" class="h-3.5 w-3.5 animate-spin" />
|
|
<Download v-else class="h-3.5 w-3.5" />
|
|
{{ t("consul.tools.exportSearchResults") }}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog v-model:open="exportOpen">
|
|
<DialogContent class="sm:max-w-lg">
|
|
<DialogHeader
|
|
><DialogTitle>{{ t("consul.tools.exportPrefix") }}</DialogTitle></DialogHeader
|
|
>
|
|
<div class="grid gap-2">
|
|
<Label>{{ t("consul.tools.prefix") }}</Label>
|
|
<Popover :open="prefixSuggestionTarget === 'export' && prefixSuggestions.length > 0">
|
|
<PopoverAnchor as-child>
|
|
<Input
|
|
v-model="exportPrefix"
|
|
autocomplete="off"
|
|
:placeholder="t('consul.tools.emptyPrefixAll')"
|
|
@input="schedulePrefixSuggestions('export', ($event.target as HTMLInputElement).value)"
|
|
@focus="schedulePrefixSuggestions('export', exportPrefix)"
|
|
@blur="closePrefixSuggestions"
|
|
@keydown="onPrefixSuggestionKeydown('export', $event)"
|
|
/>
|
|
</PopoverAnchor>
|
|
<PopoverContent align="start" side="bottom" :collision-padding="12" class="z-[60] max-h-[var(--reka-popover-content-available-height)] w-[var(--reka-popover-trigger-width)] gap-0 overflow-y-auto p-1" @open-auto-focus.prevent>
|
|
<button
|
|
v-for="(suggestion, index) in prefixSuggestions"
|
|
:key="suggestion.key"
|
|
type="button"
|
|
role="option"
|
|
:aria-selected="prefixSuggestionIndex === index"
|
|
class="flex w-full items-center px-3 py-1.5 text-left font-mono text-xs"
|
|
:class="prefixSuggestionIndex === index ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/70'"
|
|
@mouseenter="prefixSuggestionIndex = index"
|
|
@mousedown.prevent="acceptPrefixSuggestion('export', index)"
|
|
>
|
|
{{ suggestion.key }}
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<p class="text-xs text-muted-foreground">{{ t("consul.tools.exportLimitHint") }}</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" @click="exportOpen = false">{{ t("common.cancel") }}</Button>
|
|
<Button :disabled="exporting" @click="exportPrefixBundle"><Loader2 v-if="exporting" class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.export") }}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog v-model:open="transferOpen">
|
|
<DialogContent class="flex max-h-[86vh] flex-col sm:max-w-5xl">
|
|
<DialogHeader
|
|
><DialogTitle>{{ transferMode === "migration" ? t("consul.tools.migrateTitle") : t("consul.tools.importTitle") }}</DialogTitle></DialogHeader
|
|
>
|
|
<div class="grid shrink-0 gap-3 sm:grid-cols-3">
|
|
<div v-if="transferMode === 'migration'" class="grid gap-2">
|
|
<Label>{{ t("consul.tools.sourcePrefix") }}</Label>
|
|
<Popover :open="prefixSuggestionTarget === 'migration' && prefixSuggestions.length > 0">
|
|
<PopoverAnchor as-child>
|
|
<Input
|
|
v-model="transferSourcePrefix"
|
|
autocomplete="off"
|
|
:placeholder="t('consul.tools.emptyPrefixAll')"
|
|
@input="schedulePrefixSuggestions('migration', ($event.target as HTMLInputElement).value)"
|
|
@focus="schedulePrefixSuggestions('migration', transferSourcePrefix)"
|
|
@blur="closePrefixSuggestions"
|
|
@keydown="onPrefixSuggestionKeydown('migration', $event)"
|
|
/>
|
|
</PopoverAnchor>
|
|
<PopoverContent align="start" side="bottom" :collision-padding="12" class="z-[60] max-h-[var(--reka-popover-content-available-height)] w-[var(--reka-popover-trigger-width)] gap-0 overflow-y-auto p-1" @open-auto-focus.prevent>
|
|
<button
|
|
v-for="(suggestion, index) in prefixSuggestions"
|
|
:key="suggestion.key"
|
|
type="button"
|
|
role="option"
|
|
:aria-selected="prefixSuggestionIndex === index"
|
|
class="flex w-full items-center px-3 py-1.5 text-left font-mono text-xs"
|
|
:class="prefixSuggestionIndex === index ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/70'"
|
|
@mouseenter="prefixSuggestionIndex = index"
|
|
@mousedown.prevent="acceptPrefixSuggestion('migration', index)"
|
|
>
|
|
{{ suggestion.key }}
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<div class="grid gap-2">
|
|
<Label>{{ t("consul.tools.targetConnection") }}</Label>
|
|
<Select v-model="transferTargetId" @update:model-value="resetTransferPreview">
|
|
<SelectTrigger><SelectValue :placeholder="t('consul.tools.selectTarget')" /></SelectTrigger>
|
|
<SelectContent
|
|
><SelectItem v-for="connection in transferTargetConnections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem></SelectContent
|
|
>
|
|
</Select>
|
|
</div>
|
|
<div class="grid gap-2">
|
|
<Label>{{ t("consul.tools.conflictPolicy") }}</Label>
|
|
<Select v-model="transferPolicy" @update:model-value="resetTransferPreview">
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="abort">{{ t("consul.tools.policyAbort") }}</SelectItem>
|
|
<SelectItem value="skip">{{ t("consul.tools.policySkip") }}</SelectItem>
|
|
<SelectItem value="cas">{{ t("consul.tools.policyCas") }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div v-if="transferBundle" class="text-xs text-muted-foreground">
|
|
{{ t("consul.tools.bundleSummary", { count: transferBundle.entries.length, dc: transferBundle.source.datacenter || "-", ns: transferBundle.source.namespace || "-", partition: transferBundle.source.partition || "-" }) }}
|
|
</div>
|
|
<div v-if="transferMode === 'migration'" class="border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">{{ t("consul.ui.clientSideMigration") }}</div>
|
|
<div v-if="transferTargetReadOnly" class="border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">{{ t("consul.tools.targetReadOnly") }}</div>
|
|
<div v-if="transferError" class="border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ transferError }}</div>
|
|
<div v-if="transferPreview" class="flex flex-wrap gap-2 text-xs">
|
|
<Badge variant="secondary">{{ t("consul.tools.creates", { count: transferPreview.creates }) }}</Badge>
|
|
<Badge variant="secondary">{{ t("consul.tools.updates", { count: transferPreview.updates }) }}</Badge>
|
|
<Badge variant="outline">{{ t("consul.tools.unchanged", { count: transferPreview.unchanged }) }}</Badge>
|
|
<Badge variant="outline">{{ t("consul.tools.skipped", { count: transferPreview.skipped }) }}</Badge>
|
|
<Badge :variant="transferPreview.conflicts ? 'destructive' : 'outline'">{{ t("consul.tools.conflicts", { count: transferPreview.conflicts }) }}</Badge>
|
|
</div>
|
|
<div v-if="transferReport" class="flex flex-wrap gap-2 text-xs">
|
|
<Badge :variant="transferReport.atomic ? 'default' : 'destructive'">{{ transferReport.atomic ? t("consul.ui.atomicTransaction") : t("consul.ui.nonAtomicBatches", { count: reportBatchCount(transferReport.items) }) }}</Badge>
|
|
<Badge variant="outline">{{ t("consul.ui.operationTotals", { succeeded: transferReport.succeeded, conflicted: transferReport.conflicted, skipped: transferReport.skipped, failed: transferReport.failed }) }}</Badge>
|
|
</div>
|
|
<div class="min-h-0 flex-1 overflow-auto border">
|
|
<div v-for="row in transferReport?.items || transferPreview?.rows || []" :key="row.key" class="grid grid-cols-[minmax(0,1fr)_120px] gap-3 border-b px-3 py-2 text-xs">
|
|
<div class="min-w-0">
|
|
<div class="truncate font-mono">{{ row.key }}</div>
|
|
<div v-if="'batch' in row && row.batch" class="mt-1 text-muted-foreground">{{ t("consul.ui.batchOperation", { batch: row.batch, op: row.opIndex ?? "-" }) }}</div>
|
|
<div v-if="('message' in row && row.message) || ('reason' in row && row.reason)" class="mt-1 text-muted-foreground">{{ "message" in row ? row.message : "reason" in row ? row.reason : "" }}</div>
|
|
</div>
|
|
<Badge :variant="operationVariant('outcome' in row ? row.outcome : row.operation)" class="justify-center">{{ t(`consul.tools.operation.${"outcome" in row ? row.outcome : row.operation}`) }}</Badge>
|
|
</div>
|
|
<div v-if="transferLoading" 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.tools.loadingPreview") }}</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" @click="transferOpen = false">{{ t("common.close") }}</Button>
|
|
<Button v-if="transferMode === 'migration' && !transferPreview" variant="outline" :disabled="transferLoading || !transferTargetId" @click="loadMigrationPreview">{{ t("consul.tools.loadPreview") }}</Button>
|
|
<Button v-else-if="transferMode === 'import' && !transferPreview" variant="outline" :disabled="transferLoading || !transferTargetId" @click="previewTransfer">{{ t("consul.tools.loadPreview") }}</Button>
|
|
<Button v-if="transferPreview && !transferReport" :disabled="transferApplying || transferTargetReadOnly || !transferPreview.canApply" @click="executeTransfer"><Loader2 v-if="transferApplying" class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.tools.apply") }}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog v-model:open="deleteOpen">
|
|
<DialogContent class="flex max-h-[82vh] flex-col sm:max-w-3xl">
|
|
<DialogHeader
|
|
><DialogTitle>{{ t("consul.tools.deletePrefixTitle") }}</DialogTitle></DialogHeader
|
|
>
|
|
<div class="flex shrink-0 gap-2">
|
|
<div class="min-w-0 flex-1">
|
|
<Popover :open="prefixSuggestionTarget === 'delete' && prefixSuggestions.length > 0">
|
|
<PopoverAnchor as-child>
|
|
<Input
|
|
v-model="deletePrefix"
|
|
autocomplete="off"
|
|
:placeholder="t('consul.tools.deletePrefixPlaceholder')"
|
|
:disabled="Boolean(deletePreview)"
|
|
@input="schedulePrefixSuggestions('delete', ($event.target as HTMLInputElement).value)"
|
|
@focus="schedulePrefixSuggestions('delete', deletePrefix)"
|
|
@blur="closePrefixSuggestions"
|
|
@keydown="onPrefixSuggestionKeydown('delete', $event)"
|
|
/>
|
|
</PopoverAnchor>
|
|
<PopoverContent align="start" side="bottom" :collision-padding="12" class="z-[60] max-h-[var(--reka-popover-content-available-height)] w-[var(--reka-popover-trigger-width)] gap-0 overflow-y-auto p-1" @open-auto-focus.prevent>
|
|
<button
|
|
v-for="(suggestion, index) in prefixSuggestions"
|
|
:key="suggestion.key"
|
|
type="button"
|
|
role="option"
|
|
:aria-selected="prefixSuggestionIndex === index"
|
|
class="flex w-full items-center px-3 py-1.5 text-left font-mono text-xs"
|
|
:class="prefixSuggestionIndex === index ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/70'"
|
|
@mouseenter="prefixSuggestionIndex = index"
|
|
@mousedown.prevent="acceptPrefixSuggestion('delete', index)"
|
|
>
|
|
{{ suggestion.key }}
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<Button variant="outline" :disabled="deleteLoading || !deletePrefix.trim() || Boolean(deletePreview)" @click="previewDeletePrefix"><Loader2 v-if="deleteLoading" class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.tools.preview") }}</Button>
|
|
</div>
|
|
<div class="border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">{{ t("consul.tools.deletePrefixWarning") }}</div>
|
|
<div v-if="deleteError" class="border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ deleteError }}</div>
|
|
<div v-if="deletePreview" class="flex flex-wrap gap-2 text-xs">
|
|
<Badge variant="secondary">{{ t("consul.tools.candidates", { count: deletePreview.candidates.length }) }}</Badge>
|
|
<Badge :variant="deletePreview.locked ? 'destructive' : 'outline'">{{ t("consul.tools.lockedCount", { count: deletePreview.locked }) }}</Badge>
|
|
<Badge :variant="deletePreview.filteredByAcls ? 'destructive' : 'outline'">ACL {{ deletePreview.filteredByAcls ? t("consul.tools.filtered") : t("consul.tools.complete") }}</Badge>
|
|
</div>
|
|
<div v-if="deleteReport" class="flex flex-wrap gap-2 text-xs">
|
|
<Badge :variant="deleteReport.atomic ? 'default' : 'destructive'">{{ deleteReport.atomic ? t("consul.ui.atomicTransaction") : t("consul.ui.nonAtomicBatches", { count: reportBatchCount(deleteReport.items) }) }}</Badge>
|
|
<Badge variant="outline">{{ t("consul.ui.deleteOperationTotals", { succeeded: deleteReport.succeeded, conflicted: deleteReport.conflicted, failed: deleteReport.failed }) }}</Badge>
|
|
</div>
|
|
<div class="min-h-0 flex-1 overflow-auto border">
|
|
<div v-for="row in deleteReport?.items || deletePreview?.candidates || []" :key="row.key" class="grid grid-cols-[minmax(0,1fr)_140px] gap-3 border-b px-3 py-2 text-xs" :class="'session' in row && row.session ? 'bg-destructive/5' : ''">
|
|
<span class="min-w-0">
|
|
<span class="flex min-w-0 items-center gap-2"
|
|
><span class="truncate font-mono">{{ row.key }}</span
|
|
><Badge v-if="'session' in row && row.session" variant="destructive" class="shrink-0">{{ t("consul.ui.locked") }}</Badge></span
|
|
>
|
|
<span v-if="'session' in row && row.session" class="mt-1 block truncate font-mono text-muted-foreground">{{ t("consul.ui.sessionValue", { id: row.session }) }}</span>
|
|
<span v-if="'batch' in row && row.batch" class="block truncate text-muted-foreground">{{ t("consul.ui.batchOperation", { batch: row.batch, op: row.opIndex ?? "-" }) }}</span>
|
|
<span v-if="'message' in row && row.message" class="mt-1 block truncate text-destructive" :title="row.message">{{ row.message }}</span>
|
|
</span>
|
|
<Badge v-if="'outcome' in row" :variant="operationVariant(row.outcome)" class="justify-center">{{ t(`consul.tools.operation.${row.outcome}`) }}</Badge>
|
|
<span v-else class="truncate text-right font-mono text-muted-foreground">{{ row.modifyIndex }}</span>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" @click="deleteOpen = false">{{ t("common.close") }}</Button>
|
|
<Button v-if="deletePreview && !deleteReport" variant="destructive" :disabled="deleteExecuting || !deletePreview.canExecute" @click="executeDeletePrefix"><Loader2 v-if="deleteExecuting" class="mr-2 h-4 w-4 animate-spin" />{{ t("consul.tools.confirmDeletePrefix") }}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</template>
|