Files
dbx/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue
T

1357 lines
66 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { Activity, AlertTriangle, ArrowRightLeft, ChevronDown, ChevronLeft, ChevronRight, Download, Eraser, KeyRound, Loader2, Search, Trash2, Upload, Wrench } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import KvKeyBrowser from "@/components/kv/KvKeyBrowser.vue";
import EtcdAdminConsole from "@/components/etcd/EtcdAdminConsole.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
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 { isKeyInKvExportScope, kvExportFilenameStem, kvValueByteIdentity, type KvExportScopeKind, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
import { detectKvValueFormat } from "@/lib/kv/kvValueFormat";
import { useConnectionStore } from "@/stores/connectionStore";
type WorkbenchMode = "keys" | "search" | "maintenance" | "watch" | "lease";
type SearchScope = "key" | "value" | "all";
type TransferOperation = "create" | "update" | "unchanged" | "skipped" | "applied";
type EtcdExportFormat = "json" | "csv" | "markdown";
type EtcdSyncScope = "prefix" | "all";
type EtcdConflictPolicy = "ABORT" | "SKIP" | "OVERWRITE";
interface EtcdBundleEntry {
key: api.KvValue;
value: api.KvValue;
metadata?: api.KvKeyMetadata | null;
formatHint?: string;
}
interface EtcdBundle {
format: "dbx-etcd-bundle";
version: 1;
exportedAt: string;
clusterId?: string | null;
readRevision?: string | null;
prefix: string;
scopeKind?: KvExportScopeKind | "selection";
entries: EtcdBundleEntry[];
}
interface EtcdExportFile {
content: string;
filename: string;
format: EtcdExportFormat;
}
interface SearchResult {
id: string;
displayKey: string;
keyIdentity: string;
summary: api.KvKeySummary;
matchesKey: boolean;
matchesValue: boolean;
selected: boolean;
}
interface EtcdWatchPreset {
key: string;
keyBytes?: api.KvValue | null;
scope: "key" | "prefix";
}
interface EtcdMultiSelection {
key: string;
keyIdentity?: string | null;
keyBytes?: api.KvValue | null;
modRevision?: api.KvInt64 | null;
}
interface TransferRow {
id: string;
displayKey: string;
source?: EtcdBundleEntry;
target?: api.KvGetResponse;
operation: TransferOperation;
reason?: string;
selected: boolean;
}
interface ScanOptions {
onPage?: (scanned: number) => void;
maxEntries?: number;
maxEntriesMessage?: string;
}
const DEFAULT_SCAN_MAX_ENTRIES = 50_000;
const SYNC_PREVIEW_MAX_ENTRIES = 10_000;
const TRANSFER_PREVIEW_PAGE_SIZE = 100;
const TARGET_LOOKUP_CONCURRENCY = 8;
const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const browserRef = ref<InstanceType<typeof KvKeyBrowser> | null>(null);
const supportsTtl = ref(false);
const ttlCapabilityKnown = ref(false);
const leaseOptions = ref<Array<{ id: api.KvInt64; ttl: number; grantedTtl?: number }>>([]);
const ttlCapabilityRefreshIntervalMs = 5000;
let ttlCapabilityRequest = 0;
let ttlCapabilityInFlightConnection: string | null = null;
let ttlCapabilityRefreshTimer: ReturnType<typeof setInterval> | null = null;
const mode = ref<WorkbenchMode>("keys");
const operationsStatus = ref<api.KvStatusResponse | null>(null);
const operationsLoading = ref(false);
const watchPreset = ref<EtcdWatchPreset | null>(null);
const activeOperation = ref<"maintenance" | "watch" | "lease">("maintenance");
const isOperationsMode = computed(() => mode.value === "maintenance" || mode.value === "watch" || mode.value === "lease");
const keyBytesByDisplay = new Map<string, Map<string, api.KvValue>>();
const keySuggestionVersion = ref(0);
const watchKeySuggestions = computed(() => {
keySuggestionVersion.value;
return [...keyBytesByDisplay.entries()].flatMap(([key, values]) => [...values.values()].map((keyBytes) => ({ key, keyBytes })));
});
const fileInput = ref<HTMLInputElement>();
const selectedTreeKeys = ref<EtcdMultiSelection[]>([]);
const batchDeleteOpen = ref(false);
const batchDeleting = ref(false);
const searchQuery = ref("");
const searchPrefix = ref("");
const searchScope = ref<SearchScope>("all");
const searchResults = ref<SearchResult[]>([]);
const searchRunning = ref(false);
const searchScanned = ref(0);
const searchMatched = ref(0);
const searchHasRun = ref(false);
const searchResultLimitReached = ref(false);
const searchSubmittedQuery = ref("");
const searchError = ref("");
let searchCancelled = false;
let transferPreviewGeneration = 0;
const transferOpen = ref(false);
const transferMode = ref<"import" | "sync">("import");
const transferBundle = ref<EtcdBundle | null>(null);
const targetConnectionId = ref("");
const transferRows = ref<TransferRow[]>([]);
const transferLoading = ref(false);
const transferApplying = ref(false);
const transferError = ref("");
const transferKeyFilter = ref("");
const syncPrefix = ref("");
const syncScope = ref<EtcdSyncScope>("prefix");
const transferConflictPolicy = ref<EtcdConflictPolicy>("ABORT");
const transferCurrentPage = ref(1);
const transferLoadingDetail = ref("");
const transferPreviewLoaded = ref(false);
const syncConfigurationExpanded = ref(true);
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const etcdConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "etcd"));
const targetReadOnly = computed(() => Boolean(connectionStore.getConfig(targetConnectionId.value)?.read_only));
const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && isTransferRowSelectable(row)));
const selectableTransferRows = computed(() => transferRows.value.filter(isTransferRowSelectable));
const filteredTransferRows = computed(() => {
const query = transferKeyFilter.value.trim().toLocaleLowerCase();
return query ? transferRows.value.filter((row) => row.displayKey.toLocaleLowerCase().includes(query)) : transferRows.value;
});
const transferPageCount = computed(() => Math.max(1, Math.ceil(filteredTransferRows.value.length / TRANSFER_PREVIEW_PAGE_SIZE)));
const pagedTransferRows = computed(() => {
const start = (transferCurrentPage.value - 1) * TRANSFER_PREVIEW_PAGE_SIZE;
return filteredTransferRows.value.slice(start, start + TRANSFER_PREVIEW_PAGE_SIZE);
});
const selectablePagedTransferRows = computed(() => pagedTransferRows.value.filter(isTransferRowSelectable));
const allPagedTransferRowsSelected = computed(() => selectablePagedTransferRows.value.length > 0 && selectablePagedTransferRows.value.every((row) => row.selected));
const somePagedTransferRowsSelected = computed(() => !allPagedTransferRowsSelected.value && selectablePagedTransferRows.value.some((row) => row.selected));
const selectedSearchResults = computed(() => searchResults.value.filter((result) => result.selected));
const transferCreateCount = computed(() => transferRows.value.filter((row) => row.operation === "create").length);
const transferConflictCount = computed(() => transferRows.value.filter((row) => row.operation === "update").length);
const transferUnchangedCount = computed(() => transferRows.value.filter((row) => row.operation === "unchanged").length);
const transferSkippedCount = computed(() => transferRows.value.filter((row) => row.operation === "skipped").length);
const transferHasBlockingConflicts = computed(() => transferMode.value === "sync" && transferConflictPolicy.value === "ABORT" && transferConflictCount.value > 0);
const canLoadSyncPreview = computed(() => Boolean(targetConnectionId.value) && (syncScope.value === "all" || syncPrefix.value.length > 0));
const selectedTargetConnectionName = computed(() => etcdConnections.value.find((connection) => connection.id === targetConnectionId.value)?.name || "未选择");
const syncScopeSummary = computed(() => (syncScope.value === "all" ? "全部 Key" : `Prefix: ${syncPrefix.value}`));
watch(transferKeyFilter, () => {
transferCurrentPage.value = 1;
});
watch(syncPrefix, () => {
if (transferMode.value !== "sync" || !transferBundle.value) return;
transferPreviewGeneration++;
transferBundle.value = null;
transferRows.value = [];
transferError.value = "";
transferPreviewLoaded.value = false;
transferCurrentPage.value = 1;
});
watch(syncScope, () => {
if (transferMode.value !== "sync") return;
transferPreviewGeneration++;
transferBundle.value = null;
transferRows.value = [];
transferError.value = "";
transferPreviewLoaded.value = false;
transferCurrentPage.value = 1;
});
watch(transferConflictPolicy, () => {
if (transferMode.value !== "sync" || !transferPreviewLoaded.value) return;
for (const row of transferRows.value) {
if (row.operation === "create") row.selected = true;
else if (row.operation === "update") row.selected = transferConflictPolicy.value === "OVERWRITE";
else if (row.operation !== "applied") row.selected = false;
}
});
function decodeBase64(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function displayKey(key: api.KvValue): string {
if (key.encoding === "utf8") return key.data;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(key.data));
} catch {
// Binary key: show a reversible, explicit representation.
}
return `[base64:${key.data}]`;
}
function keyValue(summary: api.KvKeySummary): api.KvValue {
return summary.keyBytes ?? { encoding: "utf8", data: summary.key };
}
function keyIdentity(key: api.KvValue): string {
return kvValueByteIdentity(key);
}
function rememberKeyBytes(display: string, bytes: api.KvValue) {
const identity = keyIdentity(bytes);
const bytesByIdentity = keyBytesByDisplay.get(display) ?? new Map<string, api.KvValue>();
const isNewKey = !bytesByIdentity.has(identity);
bytesByIdentity.set(identity, bytes);
keyBytesByDisplay.set(display, bytesByIdentity);
if (isNewKey) keySuggestionVersion.value++;
return identity;
}
function rememberSummary(summary: api.KvKeySummary): api.KvKeySummary {
const bytes = keyValue(summary);
const shown = displayKey(bytes);
const identity = rememberKeyBytes(shown, bytes);
return { ...summary, key: shown, keyBytes: bytes, keyIdentity: identity };
}
function keyOptions(key: string): api.KvGetOptions {
const candidates = keyBytesByDisplay.get(key);
return { keyBytes: candidates?.size === 1 ? [...candidates.values()][0] : undefined };
}
const etcdApi = {
async listPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null, options?: api.KvListPrefixOptions | null) {
const response = await api.etcdListPrefix(connectionId, prefix, limit, continuation, options);
return { ...response, keys: response.keys.map(rememberSummary) };
},
async get(connectionId: string, key: string, options?: api.KvGetOptions | null) {
const result = await api.etcdGet(connectionId, key, { ...keyOptions(key), ...options });
if (!result.found || !result.keyBytes) return result;
const shown = displayKey(result.keyBytes);
const identity = rememberKeyBytes(shown, result.keyBytes);
return { ...result, key: shown, keyIdentity: identity };
},
getMetadata: (connectionId: string, key: string, options?: api.KvGetOptions | null) => api.etcdGet(connectionId, key, { ...keyOptions(key), ...options, metadataOnly: true }),
put: (connectionId: string, key: string, value: api.KvValue, options?: api.KvPutOptions | null) =>
api.etcdPut(connectionId, key, value, {
...options,
keyBytes: options?.keyBytes ?? (options?.expectedCreateRevision === "0" ? undefined : keyOptions(key).keyBytes),
}),
deleteKey: (connectionId: string, key: string, options?: api.KvDeleteOptions | null) => api.etcdDelete(connectionId, key, { ...options, keyBytes: options?.keyBytes ?? keyOptions(key).keyBytes }),
rename: api.etcdRename,
history: api.etcdHistory,
exportScope: exportEtcdNodeScope,
};
const labels = computed(() => ({
prefixPlaceholder: t("etcd.prefixPlaceholder"),
newKey: t("etcd.newKey"),
loadingKeys: t("etcd.loadingKeys"),
empty: t("etcd.empty"),
loadMore: t("etcd.loadMore"),
selectKey: t("etcd.selectKey"),
loadingValue: t("etcd.loadingValue"),
notFound: t("etcd.notFound"),
edit: t("etcd.edit"),
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"),
deleted: t("etcd.deleted"),
base64Readonly: t("etcd.base64Readonly"),
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"),
compare: t("etcd.compare"),
format: t("etcd.format"),
expiryMode: t("etcd.expiryMode"),
expiryPermanent: t("etcd.expiryPermanent"),
expiryPermanentHint: t("etcd.expiryPermanentHint"),
expiryTtl: t("etcd.expiryTtl"),
expiryTtlHint: t("etcd.expiryTtlHint"),
expiryLease: t("etcd.expiryLease"),
expiryLeaseHint: t("etcd.expiryLeaseHint"),
leaseId: t("etcd.leaseId"),
leasePlaceholder: t("etcd.leasePlaceholder"),
leaseInvalid: t("etcd.leaseInvalid"),
watch: t("etcd.watch"),
selectExistingLease: t("etcd.selectExistingLease"),
enterLeaseId: t("etcd.enterLeaseId"),
leasePickerHint: t("etcd.leasePickerHint"),
noLeasePickerHint: t("etcd.noLeasePickerHint"),
registryWarning: t("etcd.registryWarning"),
selectAll: t("etcd.selectAllLoaded"),
deselectAll: t("etcd.deselectAll"),
valueContent: t("etcd.valueContent"),
savePreview: t("etcd.savePreview"),
keyAlreadyExists: t("etcd.keyAlreadyExists"),
conflict: t("etcd.conflict"),
prettyJson: t("zookeeper.prettyJson"),
invalidJson: t("zookeeper.invalidJson"),
}));
async function refreshTtlCapability() {
const connectionId = props.connectionId;
if (ttlCapabilityInFlightConnection === connectionId) return;
const request = ++ttlCapabilityRequest;
ttlCapabilityInFlightConnection = connectionId;
try {
const supported = await api.etcdSupportsTtl(connectionId);
if (request !== ttlCapabilityRequest || props.connectionId !== connectionId) return;
supportsTtl.value = supported;
ttlCapabilityKnown.value = true;
} catch {
if (request !== ttlCapabilityRequest || props.connectionId !== connectionId) return;
// Keep an unknown capability unknown, and preserve the last confirmed result
// across transient Agent reconnects.
} finally {
if (request === ttlCapabilityRequest) ttlCapabilityInFlightConnection = null;
}
}
function stopTtlCapabilityRefresh() {
ttlCapabilityRequest++;
ttlCapabilityInFlightConnection = null;
if (ttlCapabilityRefreshTimer !== null) {
clearInterval(ttlCapabilityRefreshTimer);
ttlCapabilityRefreshTimer = null;
}
}
function startTtlCapabilityRefresh() {
stopTtlCapabilityRefresh();
void refreshTtlCapability();
ttlCapabilityRefreshTimer = setInterval(() => void refreshTtlCapability(), ttlCapabilityRefreshIntervalMs);
}
watch(
() => props.connectionId,
() => {
keyBytesByDisplay.clear();
supportsTtl.value = false;
ttlCapabilityKnown.value = false;
startTtlCapabilityRefresh();
},
{ immediate: true },
);
onBeforeUnmount(stopTtlCapabilityRefresh);
function valuesEqual(left?: api.KvValue | null, right?: api.KvValue | null) {
return left?.encoding === right?.encoding && left?.data === right?.data;
}
function normalizedLease(metadata?: api.KvKeyMetadata | null) {
return metadata?.lease == null ? "0" : String(metadata.lease);
}
function isTransferRowSelectable(row: TransferRow): boolean {
if (["unchanged", "skipped", "applied"].includes(row.operation)) return false;
if (transferMode.value !== "sync" || row.operation !== "update") return true;
return transferConflictPolicy.value === "OVERWRITE";
}
function transferOperationLabel(row: TransferRow): string {
if (row.operation === "create") return "新增";
if (row.operation === "update") return transferConflictPolicy.value === "OVERWRITE" ? "覆盖" : "冲突";
if (row.operation === "unchanged") return "无变化";
if (row.operation === "skipped") return "已跳过";
return "已应用";
}
function conflictPolicyLabel(policy: EtcdConflictPolicy): string {
if (policy === "ABORT") return "遇冲突终止";
if (policy === "SKIP") return "跳过冲突";
return "覆盖目标";
}
function transferOperationVariant(row: TransferRow): "default" | "secondary" | "destructive" | "outline" {
if (row.operation === "create" || row.operation === "applied") return "secondary";
return "outline";
}
async function scanConnection(connectionId: string, prefix: string, options: ScanOptions = {}): Promise<{ entries: api.KvKeySummary[]; revision: string | null }> {
const maxEntries = options.maxEntries ?? DEFAULT_SCAN_MAX_ENTRIES;
const entries: api.KvKeySummary[] = [];
let continuation: string | null = null;
let revision: string | null = null;
do {
const response = await api.etcdListPrefix(connectionId, prefix, 500, continuation, {
revision,
includeValues: true,
});
if (!revision && response.revision != null) revision = String(response.revision);
entries.push(...response.keys);
continuation = response.continuation || null;
options.onPage?.(entries.length);
if (entries.length >= maxEntries && continuation) {
throw new Error(options.maxEntriesMessage ?? `Safety limit reached: scan is limited to ${maxEntries.toLocaleString()} keys.`);
}
if (searchCancelled) break;
} while (continuation);
return { entries, revision };
}
function syncScanLimitMessage(prefix: string): string {
const scope = prefix || "整个 Keyspace";
return `同步预览最多支持 ${SYNC_PREVIEW_MAX_ENTRIES.toLocaleString()} 个 Key;“${scope}”范围过大,请使用更具体的 Prefix 后重试。`;
}
async function scanSyncScope(connectionId: string, prefix: string, onPage?: (scanned: number) => void) {
return scanConnection(connectionId, prefix, {
onPage,
maxEntries: SYNC_PREVIEW_MAX_ENTRIES,
maxEntriesMessage: syncScanLimitMessage(prefix),
});
}
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, mapper: (item: T, index: number) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
const worker = async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await mapper(items[index], index);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
return results;
}
function bundleFromSummaries(entries: api.KvKeySummary[], prefix: string, revision: string | null, scopeKind: KvExportScopeKind | "selection" = "prefix"): EtcdBundle {
const missingValue = entries.find((entry) => !entry.value);
if (missingValue) {
throw new Error(`Value data is unavailable for Key "${missingValue.key}". Update the etcd Agent, reconnect, and retry.`);
}
return {
format: "dbx-etcd-bundle",
version: 1,
exportedAt: new Date().toISOString(),
readRevision: revision,
prefix,
scopeKind,
entries: entries.map((entry) => {
const value = entry.value as api.KvValue;
return {
key: keyValue(entry),
value,
metadata: {
createRevision: entry.createRevision,
modRevision: entry.modRevision,
version: entry.version,
lease: entry.lease,
valueSize: entry.valueSize,
},
formatHint: detectKvValueFormat(value.data, value.encoding),
};
}),
};
}
function updateTreeSelection(selection: EtcdMultiSelection[]) {
selectedTreeKeys.value = selection;
}
async function exportTreeSelection(format: EtcdExportFormat) {
const selected = [...selectedTreeKeys.value];
if (!selected.length) return;
try {
const entries = await mapWithConcurrency(selected, TARGET_LOOKUP_CONCURRENCY, async (item) => {
const result = await api.etcdGet(props.connectionId, item.key, { keyBytes: item.keyBytes ?? undefined });
if (!result.found || !result.value) throw new Error(t("etcd.notFound"));
return {
key: result.key || item.key,
keyBytes: result.keyBytes ?? item.keyBytes ?? { encoding: "utf8", data: item.key },
value: result.value,
...result.metadata,
};
});
const file = buildEtcdExportFile(entries, "", null, "selection", `dbx-etcd-selection-${Date.now()}`, format);
const exported = await downloadExport(file);
if (exported) toast(t("etcd.exported", { count: entries.length }), 2500);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 4000);
}
}
function selectedTreeKeyDetails(): string {
return selectedTreeKeys.value.map((item) => item.key).join("\n");
}
async function deleteSelectedTreeKeys() {
const selected = [...selectedTreeKeys.value];
if (!selected.length || readOnly.value) return;
batchDeleting.value = true;
const completed: EtcdMultiSelection[] = [];
let deleted = 0;
let failed = false;
try {
for (const item of selected) {
const result = await api.etcdDelete(props.connectionId, item.key, {
keyBytes: item.keyBytes ?? undefined,
expectedModRevision: item.modRevision ?? undefined,
});
deleted += result.deleted;
completed.push(item);
}
toast(t("etcd.batchDeleteSuccess", { count: deleted }), 3000);
} catch (error) {
failed = true;
const message = error instanceof Error ? error.message : String(error);
toast(t("etcd.batchDeletePartial", { count: deleted, error: message }), 5000);
} finally {
if (failed) browserRef.value?.clearMultiSelection();
else browserRef.value?.removeMultiSelection(completed);
batchDeleteOpen.value = false;
batchDeleting.value = false;
if (completed.length || failed) browserRef.value?.refresh();
}
}
function csvCell(value: string): string {
return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
}
function markdownCell(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll("|", "\\|").replaceAll("\r\n", "<br>").replaceAll("\n", "<br>");
}
function summaryValue(entry: api.KvKeySummary): api.KvValue {
if (!entry.value) throw new Error(`Value data is unavailable for Key "${entry.key}". Update the etcd Agent, reconnect, and retry.`);
return entry.value;
}
function buildEtcdExportFile(entries: api.KvKeySummary[], prefix: string, revision: string | null, scopeKind: KvExportScopeKind | "selection", stem: string, format: EtcdExportFormat): EtcdExportFile {
if (format === "json") {
return {
content: JSON.stringify(bundleFromSummaries(entries, prefix, revision, scopeKind), null, 2),
filename: `${stem}.json`,
format,
};
}
const headers = ["Key", "Value", "Value Encoding", "Revision", "Version", "Lease"];
const rows = entries.map((entry) => {
const value = summaryValue(entry);
return [displayKey(keyValue(entry)), value.data, value.encoding, String(entry.modRevision ?? ""), String(entry.version ?? ""), String(entry.lease ?? "")];
});
if (format === "csv") {
return {
content: [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n"),
filename: `${stem}.csv`,
format,
};
}
return {
content: [headers, ["---", "---", "---", "---", "---", "---"], ...rows].map((row) => `| ${row.map(markdownCell).join(" | ")} |`).join("\n"),
filename: `${stem}.md`,
format,
};
}
function exportFileFilter(format: EtcdExportFormat) {
return format === "json" ? { name: "DBX etcd Bundle", extensions: ["json"] } : format === "csv" ? { name: "CSV Table", extensions: ["csv"] } : { name: "Markdown", extensions: ["md"] };
}
function exportMimeType(format: EtcdExportFormat): string {
return format === "json" ? "application/json" : format === "csv" ? "text/csv;charset=utf-8" : "text/markdown;charset=utf-8";
}
async function downloadExport(file: EtcdExportFile): Promise<boolean> {
if (isTauriRuntime()) {
const [{ save }, { writeTextFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const path = await save({
defaultPath: file.filename,
filters: [exportFileFilter(file.format)],
});
if (!path) return false;
await writeTextFile(path, file.content);
return true;
}
const url = URL.createObjectURL(new Blob([file.content], { type: exportMimeType(file.format) }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = file.filename;
anchor.click();
URL.revokeObjectURL(url);
return true;
}
async function exportAll(format: EtcdExportFormat) {
try {
searchCancelled = false;
const scan = await scanConnection(props.connectionId, "");
const file = buildEtcdExportFile(scan.entries, "", scan.revision, "prefix", `dbx-etcd-${Date.now()}`, format);
const exported = await downloadExport(file);
if (exported) toast(t("etcd.exported", { count: scan.entries.length }), 2500);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 4000);
}
}
async function exportEtcdNodeScope(connectionId: string, request: KvExportScopeRequest) {
try {
let entries: api.KvKeySummary[];
let revision: string | null;
if (request.kind === "key") {
const options = keyOptions(request.path);
const result = await api.etcdGet(connectionId, request.path, { ...options, keyBytes: request.keyBytes ?? options.keyBytes });
if (!result.found || !result.value) throw new Error(t("etcd.notFound"));
entries = [
{
key: result.key || request.path,
keyBytes: result.keyBytes ?? keyOptions(request.path).keyBytes,
value: result.value,
...result.metadata,
},
];
revision = result.metadata?.modRevision == null ? null : String(result.metadata.modRevision);
} else {
searchCancelled = false;
const scan = await scanConnection(connectionId, request.path);
entries = scan.entries.filter((entry) => isKeyInKvExportScope(displayKey(keyValue(entry)), request));
revision = scan.revision;
}
const file = buildEtcdExportFile(entries, request.path, revision, request.kind, `dbx-etcd-${kvExportFilenameStem(request.path)}-${Date.now()}`, "json");
const exported = await downloadExport(file);
if (exported) toast(t("etcd.exported", { count: entries.length }), 2500);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 4000);
}
}
function validateBundle(input: unknown): EtcdBundle {
const candidate = input as Partial<EtcdBundle>;
if (candidate.format !== "dbx-etcd-bundle" || candidate.version !== 1 || !Array.isArray(candidate.entries)) {
throw new Error("Unsupported etcd bundle. Expected DBX etcd bundle v1.");
}
for (const entry of candidate.entries) {
if (!entry || !["utf8", "base64"].includes(entry.key?.encoding) || !["utf8", "base64"].includes(entry.value?.encoding) || typeof entry.key.data !== "string" || typeof entry.value.data !== "string") {
throw new Error("Invalid etcd bundle entry.");
}
}
return candidate as EtcdBundle;
}
async function onImportFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
try {
transferBundle.value = validateBundle(JSON.parse(await file.text()));
transferMode.value = "import";
targetConnectionId.value = props.connectionId;
transferKeyFilter.value = "";
transferCurrentPage.value = 1;
transferPreviewLoaded.value = false;
transferOpen.value = true;
await previewTransfer();
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 4000);
}
}
async function openSync() {
transferMode.value = "sync";
targetConnectionId.value = etcdConnections.value.find((connection) => connection.id !== props.connectionId)?.id || "";
transferKeyFilter.value = "";
syncPrefix.value = "";
syncScope.value = "prefix";
transferConflictPolicy.value = "ABORT";
transferBundle.value = null;
transferRows.value = [];
transferError.value = "";
transferCurrentPage.value = 1;
transferLoadingDetail.value = "";
transferPreviewLoaded.value = false;
syncConfigurationExpanded.value = true;
transferOpen.value = true;
}
async function loadSyncPreview() {
const targetId = targetConnectionId.value;
if (!targetId) {
transferError.value = "请选择目标 etcd 连接后再加载预览。";
return;
}
if (syncScope.value === "prefix" && syncPrefix.value.length === 0) {
transferError.value = "请输入要同步的 Prefix,或选择“全部 Key”。";
return;
}
const prefix = syncScope.value === "all" ? "" : syncPrefix.value;
transferPreviewGeneration++;
transferBundle.value = null;
transferRows.value = [];
transferCurrentPage.value = 1;
transferPreviewLoaded.value = false;
transferLoading.value = true;
transferLoadingDetail.value = "正在读取源端 Key...";
transferError.value = "";
try {
searchCancelled = false;
const scan = await scanSyncScope(props.connectionId, prefix, (scanned) => {
transferLoadingDetail.value = `正在读取源端 Key:${scanned.toLocaleString()} 项`;
});
transferBundle.value = bundleFromSummaries(scan.entries, prefix, scan.revision);
await previewTransfer();
} catch (error) {
transferError.value = error instanceof Error ? error.message : String(error);
} finally {
transferLoading.value = false;
transferLoadingDetail.value = "";
}
}
async function previewTransfer() {
const bundle = transferBundle.value;
const targetId = targetConnectionId.value;
if (!bundle || !targetId) return;
const generation = ++transferPreviewGeneration;
transferLoading.value = true;
transferLoadingDetail.value = "正在比较目标端 Key...";
transferError.value = "";
transferPreviewLoaded.value = false;
try {
let compared = 0;
const sourceRows = await mapWithConcurrency(bundle.entries, TARGET_LOOKUP_CONCURRENCY, async (source) => {
const shown = displayKey(source.key);
if (normalizedLease(source.metadata) !== "0") {
return { id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, operation: "skipped" as const, reason: "Leased keys are skipped by default.", selected: false };
}
const target = await api.etcdGet(targetId, shown, { keyBytes: source.key });
if (generation !== transferPreviewGeneration) throw new Error("同步预览已被新的请求替换。");
compared++;
if (compared % 100 === 0 || compared === bundle.entries.length) {
transferLoadingDetail.value = `正在比较目标端 Key:${compared.toLocaleString()} / ${bundle.entries.length.toLocaleString()} 项`;
}
const operation: TransferOperation = !target.found ? "create" : valuesEqual(source.value, target.value) ? "unchanged" : "update";
const selected = operation === "create" || (operation === "update" && (transferMode.value !== "sync" || transferConflictPolicy.value === "OVERWRITE"));
return { id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, target, operation, selected };
});
if (generation !== transferPreviewGeneration) return;
transferRows.value = sourceRows;
transferCurrentPage.value = 1;
transferPreviewLoaded.value = true;
if (transferMode.value === "sync") syncConfigurationExpanded.value = false;
} catch (error) {
if (generation !== transferPreviewGeneration) return;
transferError.value = error instanceof Error ? error.message : String(error);
transferPreviewLoaded.value = false;
} finally {
if (generation === transferPreviewGeneration) {
transferLoading.value = false;
transferLoadingDetail.value = "";
}
}
}
async function applyTransfer() {
if (transferHasBlockingConflicts.value) {
transferError.value = `检测到 ${transferConflictCount.value} 个冲突。当前策略为“遇冲突终止”,未执行任何写入。`;
return;
}
const targetId = targetConnectionId.value;
const rows = [...selectedTransferRows.value];
if (!targetId || rows.length === 0) return;
// Invalidate any preview that is still resolving before writes begin.
transferPreviewGeneration++;
transferApplying.value = true;
transferLoading.value = false;
transferError.value = "";
let appliedCount = 0;
try {
for (const row of rows) {
if (!row.source) continue;
await api.etcdPut(targetId, row.displayKey, row.source.value, {
keyBytes: row.source.key,
expectedCreateRevision: row.operation === "create" ? "0" : undefined,
expectedModRevision: row.operation === "update" && row.target?.metadata?.modRevision != null ? String(row.target.metadata.modRevision) : undefined,
});
appliedCount++;
row.operation = "applied";
row.selected = false;
row.reason = t("etcd.operationApplied");
}
toast(t("etcd.transferApplied", { count: appliedCount }), 3000);
transferOpen.value = false;
if (targetId === props.connectionId) browserRef.value?.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Rebuild the preview so an ambiguous network failure or a successful
// prefix of the batch is reflected before the user retries.
let previewError = "";
let previewRefreshed = false;
if (targetConnectionId.value === targetId) {
await previewTransfer();
previewError = transferError.value;
previewRefreshed = !previewError;
} else {
previewError = t("etcd.targetChangedDuringTransfer");
}
transferError.value = previewRefreshed ? t("etcd.transferPartiallyApplied", { count: appliedCount, error: message }) : t("etcd.transferPartialRefreshFailed", { count: appliedCount, error: message, previewError });
} finally {
transferApplying.value = false;
}
}
function onTransferTargetChange(value: unknown) {
transferPreviewGeneration++;
targetConnectionId.value = String(value ?? "");
transferRows.value = [];
transferCurrentPage.value = 1;
transferPreviewLoaded.value = false;
if (transferMode.value === "sync") {
transferBundle.value = null;
} else if (targetConnectionId.value && transferBundle.value) {
void previewTransfer();
}
}
function toggleTransferSelection() {
const selected = !allPagedTransferRowsSelected.value;
for (const row of selectablePagedTransferRows.value) row.selected = selected;
}
function clearTransferSelection() {
for (const row of selectableTransferRows.value) row.selected = false;
}
function searchValueDisplay(value?: api.KvValue | null): string {
if (!value) return "";
if (value.encoding === "utf8") return value.data;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(value.data));
} catch {
return `[base64:${value.data}]`;
}
}
function searchSegments(value: string) {
const query = searchSubmittedQuery.value;
if (!query) return [{ text: value, matched: false }];
const normalizedValue = value.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const segments: Array<{ text: string; matched: boolean }> = [];
let from = 0;
let index = normalizedValue.indexOf(normalizedQuery, from);
while (index >= 0) {
if (index > from) segments.push({ text: value.slice(from, index), matched: false });
segments.push({ text: value.slice(index, index + query.length), matched: true });
from = index + query.length;
index = normalizedValue.indexOf(normalizedQuery, from);
}
if (from < value.length) segments.push({ text: value.slice(from), matched: false });
return segments.length ? segments : [{ text: value, matched: false }];
}
function toggleSearchSelection() {
const next = selectedSearchResults.value.length !== searchResults.value.length;
for (const result of searchResults.value) result.selected = next;
}
function clearSearchResults() {
searchResults.value = [];
searchScanned.value = 0;
searchMatched.value = 0;
searchHasRun.value = false;
searchResultLimitReached.value = false;
searchSubmittedQuery.value = "";
searchError.value = "";
searchCancelled = false;
}
async function runSearch() {
const query = searchQuery.value.trim();
if (!query) return;
searchRunning.value = true;
searchCancelled = false;
searchScanned.value = 0;
searchMatched.value = 0;
searchHasRun.value = true;
searchResultLimitReached.value = false;
searchSubmittedQuery.value = query;
searchError.value = "";
searchResults.value = [];
try {
const scan = await scanConnection(props.connectionId, searchPrefix.value, { onPage: (count) => (searchScanned.value = count) });
const normalized = query.toLocaleLowerCase();
const matches = scan.entries
.map((entry) => {
const shown = displayKey(keyValue(entry)).toLocaleLowerCase();
const value = searchValueDisplay(entry.value).toLocaleLowerCase();
const matchesKey = searchScope.value !== "value" && shown.includes(normalized);
const matchesValue = searchScope.value !== "key" && value.includes(normalized);
return { entry, matchesKey, matchesValue };
})
.filter((match) => match.matchesKey || match.matchesValue);
searchMatched.value = matches.length;
searchResultLimitReached.value = matches.length > 1000;
searchResults.value = matches.slice(0, 1000).map(({ entry: summary, matchesKey, matchesValue }) => {
const bytes = keyValue(summary);
const shown = displayKey(bytes);
const identity = rememberKeyBytes(shown, bytes);
return { id: `${identity}:${summary.modRevision || ""}`, displayKey: shown, keyIdentity: identity, summary: { ...summary, key: shown, keyBytes: bytes, keyIdentity: identity }, matchesKey, matchesValue, selected: true };
});
} catch (error) {
searchError.value = error instanceof Error ? error.message : String(error);
} finally {
searchRunning.value = false;
}
}
function cancelSearch() {
searchCancelled = true;
}
async function openSearchResult(result: SearchResult) {
mode.value = "keys";
await nextTick();
await (browserRef.value as any)?.selectKey({ key: result.displayKey, keyIdentity: result.keyIdentity, keyBytes: keyValue(result.summary) });
}
async function openOperations(nextMode: Extract<WorkbenchMode, "maintenance" | "watch" | "lease">) {
activeOperation.value = nextMode;
mode.value = nextMode;
operationsLoading.value = true;
try {
operationsStatus.value = await api.etcdStatus(props.connectionId);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 5000);
} finally {
operationsLoading.value = false;
}
}
function openWatchForKey(route: { key: string; keyBytes?: api.KvValue | null }) {
watchPreset.value = { key: route.key, keyBytes: route.keyBytes ?? null, scope: "key" };
}
function openWatchWorkspaceAfterCreate() {
watchPreset.value = null;
void openOperations("watch");
}
async function refreshLeaseOptions() {
try {
leaseOptions.value = (await api.etcdLeaseList(props.connectionId)).leases;
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 4000);
}
}
function refreshOperations() {
void openOperations(activeOperation.value);
}
async function exportSearchResults(format: EtcdExportFormat) {
const selected = selectedSearchResults.value.map((result) => result.summary);
if (!selected.length) return;
try {
const file = buildEtcdExportFile(selected, searchPrefix.value, null, "selection", `dbx-etcd-search-${Date.now()}`, format);
const exported = await downloadExport(file);
if (exported) toast(t("etcd.exported", { count: selected.length }), 2500);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 4000);
}
}
function focusSearch(): boolean {
if (mode.value === "keys") return browserRef.value?.focusSearch() ?? false;
mode.value = "search";
return true;
}
function refresh(): boolean {
if (mode.value === "keys") return browserRef.value?.refresh() ?? false;
if (mode.value === "search") void runSearch();
if (isOperationsMode.value) void openOperations(activeOperation.value);
return true;
}
defineExpose({ focusSearch, refresh });
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="flex h-14 shrink-0 items-center gap-3 border-b px-4">
<div class="flex rounded-md border p-0.5">
<Button size="sm" :variant="mode === 'keys' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-3 text-sm" @click="mode = 'keys'"><KeyRound class="h-4 w-4" /> {{ t("etcd.key") }}</Button>
<Button size="sm" :variant="mode === 'search' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-3 text-sm" @click="mode = 'search'"><Search class="h-4 w-4" /> {{ t("etcd.globalSearch") }}</Button>
</div>
<Button size="sm" :variant="mode === 'maintenance' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('maintenance')"><Wrench class="h-4 w-4" />{{ t("etcd.admin.maintenance") }}</Button>
<Button
size="sm"
:variant="mode === 'watch' ? 'secondary' : 'ghost'"
class="h-8 gap-1.5 px-2.5 text-sm"
@click="
watchPreset = null;
void openOperations('watch');
"
><Activity class="h-4 w-4" />{{ t("etcd.admin.watch") }}</Button
>
<Button size="sm" :variant="mode === 'lease' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('lease')"><KeyRound class="h-4 w-4" />{{ t("etcd.admin.lease") }}</Button>
<div class="flex-1" />
<Badge v-if="readOnly" variant="outline">{{ t("connection.readOnly") }}</Badge>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="sm" variant="outline" class="h-8 gap-1.5"><Download class="h-3.5 w-3.5" /> {{ t("etcd.export") }} <ChevronDown class="h-3.5 w-3.5" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-auto whitespace-nowrap">
<DropdownMenuItem @select="void exportAll('json')">{{ t("etcd.exportAllJson") }}</DropdownMenuItem>
<DropdownMenuItem @select="void exportAll('csv')">{{ t("etcd.exportAllCsv") }}</DropdownMenuItem>
<DropdownMenuItem @select="void exportAll('markdown')">{{ t("etcd.exportAllMarkdown") }}</DropdownMenuItem>
<template v-if="selectedTreeKeys.length">
<DropdownMenuSeparator />
<DropdownMenuItem @select="void exportTreeSelection('json')">{{ t("etcd.exportSelectionJson") }}</DropdownMenuItem>
<DropdownMenuItem @select="void exportTreeSelection('csv')">{{ t("etcd.exportSelectionCsv") }}</DropdownMenuItem>
<DropdownMenuItem @select="void exportTreeSelection('markdown')">{{ t("etcd.exportSelectionMarkdown") }}</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" variant="destructive" class="h-8 gap-1.5" :disabled="readOnly || selectedTreeKeys.length === 0 || batchDeleting" @click="batchDeleteOpen = true"><Trash2 class="h-3.5 w-3.5" />{{ t("etcd.delete") }}</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly" @click="fileInput?.click()"><Upload class="h-3.5 w-3.5" /> {{ t("etcd.import") }}</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="etcdConnections.length < 2" @click="openSync"><ArrowRightLeft class="h-3.5 w-3.5" /> {{ t("etcd.sync") }}</Button>
<input ref="fileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFile" />
</div>
<KvKeyBrowser
v-if="mode === 'keys'"
ref="browserRef"
class="min-h-0 flex-1"
:connection-id="props.connectionId"
:api="etcdApi"
:labels="labels"
:supports-ttl="supportsTtl"
:supports-lease-binding="true"
:ttl-capability-known="ttlCapabilityKnown"
:enable-node-actions="true"
:safe-write="true"
:allow-binary-edit="true"
: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"
@selection-change="updateTreeSelection"
/>
<div v-if="mode === 'search'" class="flex min-h-0 flex-1 flex-col">
<form class="grid shrink-0 gap-2 border-b p-3 md:grid-cols-[minmax(160px,240px)_140px_minmax(0,1fr)_auto]" @submit.prevent="runSearch">
<Input v-model="searchPrefix" :placeholder="t('etcd.searchPrefix')" autocomplete="off" />
<Select v-model="searchScope" :disabled="searchRunning">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("etcd.searchAll") }}</SelectItem>
<SelectItem value="key">Key</SelectItem>
<SelectItem value="value">Value</SelectItem>
</SelectContent>
</Select>
<Input v-model="searchQuery" :placeholder="t('etcd.searchPlaceholder')" autocomplete="off" />
<div class="flex gap-2">
<Button type="submit" :disabled="searchRunning || !searchQuery.trim()"><Loader2 v-if="searchRunning" class="mr-2 h-4 w-4 animate-spin" />{{ t("etcd.globalSearch") }}</Button>
<Button v-if="searchRunning" type="button" variant="outline" @click="cancelSearch">{{ t("common.cancel") }}</Button>
</div>
</form>
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
<Badge variant="outline">已扫描 {{ searchScanned }} 个 Key</Badge>
<Badge variant="outline">命中 {{ searchMatched }} 个</Badge>
<Badge v-if="searchResults.length" variant="outline">已选 {{ selectedSearchResults.length }} 个</Badge>
<Badge v-if="searchRunning" variant="outline" class="gap-1.5"><Loader2 class="h-3 w-3 animate-spin" />扫描中</Badge>
<Badge v-else-if="searchCancelled && searchHasRun" variant="outline" class="border-amber-500/50 text-amber-700 dark:text-amber-300">已取消,结果可能不完整</Badge>
<Badge v-if="searchResultLimitReached" variant="outline" class="border-amber-500/50 text-amber-700 dark:text-amber-300">仅显示前 1,000 个结果</Badge>
<div class="flex-1" />
<Button v-if="searchResults.length" size="sm" variant="ghost" class="h-7 px-2" @click="toggleSearchSelection">{{ selectedSearchResults.length === searchResults.length ? "取消全选" : "全选结果" }}</Button>
<Button v-if="searchHasRun || searchError" size="sm" variant="ghost" class="h-7 gap-1.5 px-2" :disabled="searchRunning" :title="t('etcd.clearSearchResults')" @click="clearSearchResults"><Eraser class="h-3.5 w-3.5" />{{ t("etcd.clearSearchResults") }}</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="sm" variant="outline" class="h-7 gap-1.5" :disabled="selectedSearchResults.length === 0"><Download class="h-3.5 w-3.5" /> {{ t("etcd.exportResults") }} <ChevronDown class="h-3.5 w-3.5" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-40">
<DropdownMenuItem @select="void exportSearchResults('json')">JSON(可导入)</DropdownMenuItem>
<DropdownMenuItem @select="void exportSearchResults('csv')">表格(CSV)</DropdownMenuItem>
<DropdownMenuItem @select="void exportSearchResults('markdown')">Markdown</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div v-if="searchError" class="border-b border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{{ searchError }}</div>
<div v-if="searchResultLimitReached" class="border-b border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">结果过多,已保留前 1,000 项用于查看和导出。请使用 Prefix 缩小范围。</div>
<div class="min-h-0 flex-1 overflow-auto">
<div v-if="searchResults.length" class="divide-y">
<div v-for="result in searchResults" :key="result.id" class="flex min-w-0 gap-3 px-3 py-3 transition-colors hover:bg-accent/50">
<div class="flex w-5 shrink-0 items-start pt-1"><input v-model="result.selected" type="checkbox" class="h-4 w-4 accent-primary" :aria-label="`选择 ${result.displayKey}`" /></div>
<button type="button" class="min-w-0 flex-1 text-left" @click="openSearchResult(result)">
<div class="flex min-w-0 items-center gap-2">
<code class="min-w-0 flex-1 truncate text-sm font-medium text-primary"
><template v-for="(segment, index) in searchSegments(result.displayKey)" :key="index"
><mark v-if="segment.matched && result.matchesKey" class="rounded-sm bg-amber-300/80 px-0.5 text-foreground dark:bg-amber-500/40">{{ segment.text }}</mark
><span v-else>{{ segment.text }}</span></template
></code
>
<Badge v-if="result.matchesKey" variant="secondary" class="shrink-0">Key</Badge>
<Badge v-if="result.matchesValue" variant="outline" class="shrink-0">Value</Badge>
<span class="hidden shrink-0 font-mono text-xs text-muted-foreground sm:inline">rev {{ result.summary.modRevision || "-" }}</span>
<ChevronRight class="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
<pre
class="mt-1.5 max-h-14 overflow-hidden text-ellipsis whitespace-pre-wrap break-all font-mono text-xs text-muted-foreground"
><template v-for="(segment, index) in searchSegments(searchValueDisplay(result.summary.value) || '(空值)')" :key="index"><mark v-if="segment.matched && result.matchesValue" class="rounded-sm bg-amber-300/80 px-0.5 text-foreground dark:bg-amber-500/40">{{ segment.text }}</mark><span v-else>{{ segment.text }}</span></template></pre>
<span class="mt-1 block font-mono text-[11px] text-muted-foreground sm:hidden">rev {{ result.summary.modRevision || "-" }}</span>
</button>
</div>
</div>
<div v-else-if="searchRunning" class="flex h-52 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />正在扫描 Key...</div>
<div v-else-if="searchHasRun" class="flex h-52 items-center justify-center px-6 text-center text-sm text-muted-foreground">{{ searchCancelled ? "搜索已取消,未找到可显示的结果。" : "未找到匹配的 Key 或 Value。" }}</div>
<div v-else class="flex h-52 items-center justify-center px-6 text-center text-sm text-muted-foreground">输入关键词后开始搜索。可使用 Prefix 限定扫描范围。</div>
</div>
</div>
<div v-if="operationsStatus || operationsLoading || watchPreset" v-show="isOperationsMode" class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="operationsLoading && !operationsStatus" class="flex h-32 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />加载集群状态...</div>
<EtcdAdminConsole
v-else-if="operationsStatus || watchPreset"
:connection-id="connectionId"
:status="operationsStatus"
:sections="[activeOperation]"
:initial-section="activeOperation"
:watch-preset="watchPreset"
:watch-key-suggestions="watchKeySuggestions"
@refresh="refreshOperations"
@watch-created="openWatchWorkspaceAfterCreate"
@watch-dialog-dismissed="watchPreset = null"
/>
</div>
<Dialog v-model:open="transferOpen">
<DialogContent class="flex h-[min(86vh,820px)] max-w-[min(96vw,1180px)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader class="shrink-0 gap-0 border-b px-5 py-3">
<DialogTitle>{{ transferMode === "sync" ? "同步 Key 到其他连接" : t("etcd.importPreview") }}</DialogTitle>
<p v-if="transferMode === 'sync'" class="mt-1 text-xs leading-4 text-muted-foreground">单向复制源端 Key,不会删除源端或目标端数据。</p>
<template v-if="transferMode === 'sync'">
<div v-if="transferPreviewLoaded && !syncConfigurationExpanded" class="mt-3 flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border bg-muted/20 px-3 py-2 text-xs">
<span class="text-muted-foreground"
>目标 <strong class="ml-1 font-medium text-foreground">{{ selectedTargetConnectionName }}</strong></span
>
<span class="min-w-0 text-muted-foreground"
>范围 <strong class="ml-1 font-mono font-medium text-foreground">{{ syncScopeSummary }}</strong></span
>
<span class="text-muted-foreground"
>策略 <strong class="ml-1 font-medium text-foreground">{{ conflictPolicyLabel(transferConflictPolicy) }}</strong></span
>
<Button size="sm" variant="ghost" class="ml-auto h-7 px-2 text-xs" @click="syncConfigurationExpanded = true">调整设置</Button>
</div>
<div v-else class="mt-3 rounded-md border bg-muted/10 p-3">
<div class="grid gap-3 lg:grid-cols-[minmax(180px,0.75fr)_auto_minmax(260px,1.35fr)]">
<div class="flex min-w-0 flex-col gap-1.5">
<span class="text-xs font-medium text-foreground">目标连接</span>
<Select :model-value="targetConnectionId" :disabled="transferLoading || transferApplying" @update:model-value="onTransferTargetChange">
<SelectTrigger class="h-9"><SelectValue :placeholder="t('etcd.targetConnection')" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in etcdConnections.filter((connection) => connection.id !== props.connectionId)" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex min-w-0 flex-col gap-1.5">
<span class="text-xs font-medium text-foreground">同步范围</span>
<div class="flex h-9 w-fit max-w-full rounded-md border bg-background p-0.5">
<label class="cursor-pointer">
<input v-model="syncScope" type="radio" name="etcd-sync-scope" value="prefix" class="sr-only" :disabled="transferLoading || transferApplying" />
<span class="flex h-8 items-center rounded px-3 text-xs" :class="syncScope === 'prefix' ? 'bg-secondary font-medium text-secondary-foreground' : 'text-muted-foreground hover:text-foreground'">Prefix</span>
</label>
<label class="cursor-pointer">
<input v-model="syncScope" type="radio" name="etcd-sync-scope" value="all" class="sr-only" :disabled="transferLoading || transferApplying" />
<span class="flex h-8 items-center rounded px-3 text-xs" :class="syncScope === 'all' ? 'bg-secondary font-medium text-secondary-foreground' : 'text-muted-foreground hover:text-foreground'">全部 Key</span>
</label>
</div>
</div>
<label v-if="syncScope === 'prefix'" class="flex min-w-0 flex-col gap-1.5">
<span class="text-xs font-medium text-foreground">Prefix</span>
<span class="relative">
<Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="syncPrefix" class="h-9 w-full pl-9 font-mono text-sm" :disabled="transferLoading || transferApplying" autocomplete="off" placeholder="例如 /apps/ 或 test/" @keyup.enter="void loadSyncPreview()" />
</span>
</label>
<div v-else class="flex min-w-0 flex-col gap-1.5">
<span class="text-xs font-medium text-foreground">扫描范围</span>
<div class="flex h-9 items-center rounded-md bg-muted/50 px-3 text-xs text-muted-foreground">整个 Keyspace,最多预览 10,000 个 Key</div>
</div>
</div>
<div class="mt-3 grid gap-2 border-t pt-3 lg:grid-cols-[auto_minmax(0,1fr)_auto] lg:items-center">
<div class="flex flex-wrap items-center gap-2">
<span class="mr-1 text-xs font-medium text-foreground">冲突策略</span>
<div class="flex h-9 rounded-md border bg-background p-0.5">
<label v-for="policy in ['ABORT', 'SKIP', 'OVERWRITE'] as EtcdConflictPolicy[]" :key="policy" class="cursor-pointer">
<input v-model="transferConflictPolicy" type="radio" name="etcd-conflict-policy" :value="policy" class="sr-only" :disabled="transferLoading || transferApplying" />
<span class="flex h-8 items-center rounded px-3 text-xs" :class="transferConflictPolicy === policy ? 'bg-secondary font-medium text-secondary-foreground' : 'text-muted-foreground hover:text-foreground'">{{ conflictPolicyLabel(policy) }}</span>
</label>
</div>
</div>
<p class="min-w-0 text-xs leading-5" :class="transferConflictPolicy === 'OVERWRITE' ? 'text-destructive' : 'text-muted-foreground'">
<AlertTriangle v-if="transferConflictPolicy === 'OVERWRITE'" class="mr-1 inline h-3.5 w-3.5 -translate-y-px" />{{
transferConflictPolicy === "ABORT" ? "发现同名异值 Key 时停止,不执行任何写入。" : transferConflictPolicy === "SKIP" ? "跳过同名异值 Key,只创建目标端不存在的 Key。" : "以源端值覆盖目标端同名 Key,请在执行前核对冲突项。"
}}
</p>
<Button v-if="transferPreviewLoaded" size="sm" variant="ghost" class="h-7 px-2 text-xs" @click="syncConfigurationExpanded = false">收起设置</Button>
</div>
</div>
</template>
<div v-else class="mt-3">
<Select :model-value="targetConnectionId" :disabled="transferLoading || transferApplying" @update:model-value="onTransferTargetChange">
<SelectTrigger><SelectValue :placeholder="t('etcd.targetConnection')" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in etcdConnections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
</SelectContent>
</Select>
</div>
</DialogHeader>
<div v-if="transferError" class="border-b px-5 py-2 text-sm text-destructive">{{ transferError }}</div>
<div v-if="targetReadOnly" class="border-b bg-amber-500/10 px-5 py-2 text-xs text-amber-700 dark:text-amber-300">{{ t("connection.readOnly") }}: {{ t("etcd.targetReadOnly") }}</div>
<div class="min-h-0 flex-1">
<div v-if="transferLoading" class="flex h-full flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<span class="flex items-center"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("etcd.preparingPreview") }}</span
><span v-if="transferLoadingDetail" class="text-xs">{{ transferLoadingDetail }}</span>
</div>
<div v-else-if="transferMode === 'sync' && !transferPreviewLoaded" class="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">选择同步范围、目标连接和冲突策略后生成预览。Prefix 会在服务端限制扫描范围,结果不会在输入过程中自动加载。</div>
<div v-else class="flex h-full min-h-0 flex-col">
<div v-if="transferMode === 'sync'" class="flex shrink-0 flex-wrap items-center gap-2 border-b px-5 py-2.5 text-xs text-muted-foreground">
<Badge variant="outline">共 {{ transferRows.length }} 项</Badge>
<Badge variant="outline" class="text-emerald-700 dark:text-emerald-300">新增 {{ transferCreateCount }}</Badge>
<Badge v-if="transferConflictCount" variant="outline" class="text-amber-700 dark:text-amber-300">冲突 {{ transferConflictCount }}</Badge>
<Badge v-if="transferUnchangedCount" variant="outline">无变化 {{ transferUnchangedCount }}</Badge>
<Badge v-if="transferSkippedCount" variant="outline">跳过 {{ transferSkippedCount }}</Badge>
<span v-if="transferHasBlockingConflicts" class="ml-auto text-destructive">当前策略要求解决冲突后才能执行</span>
</div>
<div class="flex shrink-0 items-center gap-2 border-b px-5 py-2.5">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="transferKeyFilter" class="h-8 pl-8 text-sm" autocomplete="off" placeholder="筛选 Key" />
</div>
<span class="shrink-0 text-xs text-muted-foreground">{{ filteredTransferRows.length }} / {{ transferRows.length }}</span>
<div v-if="filteredTransferRows.length > TRANSFER_PREVIEW_PAGE_SIZE" class="flex items-center gap-1 text-xs text-muted-foreground">
<Button size="sm" variant="ghost" class="h-8 w-8 p-0" title="上一页" :disabled="transferCurrentPage <= 1" @click="transferCurrentPage--"><ChevronLeft class="h-4 w-4" /></Button>
<span class="min-w-16 text-center">{{ transferCurrentPage }} / {{ transferPageCount }}</span>
<Button size="sm" variant="ghost" class="h-8 w-8 p-0" title="下一页" :disabled="transferCurrentPage >= transferPageCount" @click="transferCurrentPage++"><ChevronRight class="h-4 w-4" /></Button>
</div>
<Button size="sm" variant="ghost" class="h-8 shrink-0 px-2.5 text-xs" :disabled="transferApplying || selectedTransferRows.length === 0" @click="clearTransferSelection">清空选择</Button>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<table class="w-full text-left text-sm">
<thead class="sticky top-0 bg-muted/90 text-xs text-muted-foreground">
<tr>
<th class="w-10 px-3 py-2">
<input
type="checkbox"
class="h-4 w-4 accent-primary"
aria-label="全选当前页中可执行的操作"
:checked="allPagedTransferRowsSelected"
:indeterminate="somePagedTransferRowsSelected"
:disabled="transferApplying || selectablePagedTransferRows.length === 0"
@change="toggleTransferSelection"
/>
</th>
<th class="px-3 py-2">Key</th>
<th class="px-3 py-2">{{ t("etcd.operation") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in pagedTransferRows" :key="row.id" class="border-b">
<td class="px-3 py-2"><input v-model="row.selected" type="checkbox" class="h-4 w-4 accent-primary" :disabled="!isTransferRowSelectable(row)" /></td>
<td class="max-w-xl truncate px-3 py-2 font-mono text-xs">{{ row.displayKey }}</td>
<td class="px-3 py-2">
<Badge :variant="transferOperationVariant(row)" :class="row.operation === 'update' && transferConflictPolicy !== 'OVERWRITE' ? 'border-amber-500/50 text-amber-700 dark:text-amber-300' : ''">{{ transferOperationLabel(row) }}</Badge>
</td>
</tr>
</tbody>
</table>
<div v-if="filteredTransferRows.length === 0" class="flex h-32 items-center justify-center text-sm text-muted-foreground">未找到匹配的 Key</div>
</div>
</div>
</div>
<DialogFooter class="shrink-0 !mx-0 !mb-0 rounded-b-xl border-t !px-5 !py-3">
<span class="mr-auto text-xs text-muted-foreground">{{ t("etcd.selectedOperations", { count: selectedTransferRows.length }) }}</span>
<Button variant="outline" :disabled="transferApplying" @click="transferOpen = false">{{ t("common.cancel") }}</Button>
<Button v-if="transferMode === 'sync' && !transferPreviewLoaded" :disabled="transferLoading || transferApplying || !canLoadSyncPreview" @click="void loadSyncPreview()"><Loader2 v-if="transferLoading" class="mr-2 h-4 w-4 animate-spin" />预览</Button>
<Button
v-else
:variant="transferMode === 'sync' && transferConflictPolicy === 'OVERWRITE' && transferConflictCount > 0 ? 'destructive' : 'default'"
:disabled="targetReadOnly || transferLoading || transferApplying || transferHasBlockingConflicts || selectedTransferRows.length === 0"
@click="applyTransfer"
>
<Loader2 v-if="transferApplying" class="mr-2 h-4 w-4 animate-spin" />
{{ t("etcd.applyOperations") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="batchDeleteOpen"
:title="t('etcd.batchDeleteTitle')"
:message="t('etcd.batchDeleteConfirm', { count: selectedTreeKeys.length })"
:details="selectedTreeKeyDetails()"
:confirm-label="t('etcd.batchDelete')"
:loading="batchDeleting"
:close-on-confirm="false"
@confirm="deleteSelectedTreeKeys"
/>
</div>
</template>