fix(etcd): preserve raw key identity in browser actions

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-27 13:18:53 +08:00
1 parent 122853d089
commit 39d0f5b30f
69 files changed
+6577 -742

No files matched your search

@@ -0,0 +1,584 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Activity, AlertTriangle, CheckCircle2, Clock3, Cpu, Database, Eye, Gauge, HardDrive, KeyRound, Loader2, Network, Radio, RefreshCw, Server } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import * as api from "@/lib/backend/api";
import { classifyEtcdDashboardError } from "@/lib/kv/etcdDashboardError";
import { counterMapRates, counterRate, histogramAverageMilliseconds, histogramMapAverageMilliseconds, ratePercentage } from "@/lib/kv/etcdDashboardMetrics";
const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const status = ref<api.KvStatusResponse | null>(null);
const previousMetrics = ref<api.KvPrometheusMetrics | null>(null);
const loading = ref(false);
const error = ref("");
const unsupported = ref(false);
const refreshSeconds = ref(0);
let timer: ReturnType<typeof setInterval> | null = null;
let loadGeneration = 0;
let loadingConnectionId: string | null = null;
const reachable = computed(() => status.value?.members.filter((member) => member.reachable).length ?? 0);
const totalDbSize = computed(() => status.value?.members.reduce((sum, member) => sum + Number(member.dbSize || 0), 0) ?? 0);
const totalDbSizeInUse = computed(() => status.value?.members.reduce((sum, member) => sum + Number(member.dbSizeInUse || 0), 0) ?? 0);
const fragmentation = computed(() => (totalDbSize.value > 0 ? Math.max(0, 1 - totalDbSizeInUse.value / totalDbSize.value) : 0));
const metrics = computed(() => status.value?.metrics ?? null);
const requestRate = computed(() => counterRate(metrics.value, previousMetrics.value, "grpcRequestsTotal"));
const requestFailureRate = computed(() => counterRate(metrics.value, previousMetrics.value, "grpcFailuresTotal"));
const requestFailurePercent = computed(() => ratePercentage(requestFailureRate.value, requestRate.value));
const proposalFailureRate = computed(() => counterRate(metrics.value, previousMetrics.value, "proposalsFailedTotal"));
const clientReceivedRate = computed(() => counterRate(metrics.value, previousMetrics.value, "clientReceivedBytesTotal"));
const clientSentRate = computed(() => counterRate(metrics.value, previousMetrics.value, "clientSentBytesTotal"));
const peerReceivedRate = computed(() => counterRate(metrics.value, previousMetrics.value, "peerReceivedBytesTotal"));
const peerSentRate = computed(() => counterRate(metrics.value, previousMetrics.value, "peerSentBytesTotal"));
const processReceivedRate = computed(() => counterRate(metrics.value, previousMetrics.value, "processReceivedBytesTotal"));
const processTransmittedRate = computed(() => counterRate(metrics.value, previousMetrics.value, "processTransmittedBytesTotal"));
const walWriteRate = computed(() => counterRate(metrics.value, previousMetrics.value, "walWriteBytesTotal"));
const mvccRangeRate = computed(() => counterRate(metrics.value, previousMetrics.value, "mvccRangeTotal"));
const mvccPutRate = computed(() => counterRate(metrics.value, previousMetrics.value, "mvccPutTotal"));
const mvccDeleteRate = computed(() => counterRate(metrics.value, previousMetrics.value, "mvccDeleteTotal"));
const mvccTxnRate = computed(() => counterRate(metrics.value, previousMetrics.value, "mvccTxnTotal"));
const mvccEventRate = computed(() => counterRate(metrics.value, previousMetrics.value, "mvccEventsTotal"));
const leaseGrantedRate = computed(() => counterRate(metrics.value, previousMetrics.value, "leaseGrantedTotal"));
const leaseRenewedRate = computed(() => counterRate(metrics.value, previousMetrics.value, "leaseRenewedTotal"));
const leaseRevokedRate = computed(() => counterRate(metrics.value, previousMetrics.value, "leaseRevokedTotal"));
const leaseExpiredRate = computed(() => counterRate(metrics.value, previousMetrics.value, "leaseExpiredTotal"));
const cpuPercent = computed(() => {
const rate = counterRate(metrics.value, previousMetrics.value, "cpuSecondsTotal");
return rate == null ? null : rate * 100;
});
const walFsyncMs = computed(() => histogramAverageMilliseconds(metrics.value, previousMetrics.value, "walFsyncDurationSecondsSum", "walFsyncDurationSecondsCount"));
const backendCommitMs = computed(() => histogramAverageMilliseconds(metrics.value, previousMetrics.value, "backendCommitDurationSecondsSum", "backendCommitDurationSecondsCount"));
const walWriteMs = computed(() => histogramAverageMilliseconds(metrics.value, previousMetrics.value, "walWriteDurationSecondsSum", "walWriteDurationSecondsCount"));
const backendSnapshotMs = computed(() => histogramAverageMilliseconds(metrics.value, previousMetrics.value, "backendSnapshotDurationSecondsSum", "backendSnapshotDurationSecondsCount"));
const backendDefragMs = computed(() => histogramAverageMilliseconds(metrics.value, previousMetrics.value, "backendDefragDurationSecondsSum", "backendDefragDurationSecondsCount"));
const goGcMs = computed(() => histogramAverageMilliseconds(metrics.value, previousMetrics.value, "goGcDurationSecondsSum", "goGcDurationSecondsCount"));
const proposalLag = computed(() => {
const committed = metrics.value?.proposalsCommittedTotal;
const applied = metrics.value?.proposalsAppliedTotal;
return typeof committed === "number" && typeof applied === "number" ? Math.max(0, committed - applied) : null;
});
const fdPercent = computed(() => {
const open = metrics.value?.openFds;
const max = metrics.value?.maxFds;
return typeof open === "number" && typeof max === "number" && max > 0 ? (open / max) * 100 : null;
});
const quotaPercent = computed(() => {
const used = metrics.value?.dbSizeMetricBytes;
const quota = metrics.value?.quotaBackendBytes;
return typeof used === "number" && typeof quota === "number" && quota > 0 ? (used / quota) * 100 : null;
});
const averageLeaseTtl = computed(() => {
const sum = metrics.value?.leaseTtlSecondsSum;
const count = metrics.value?.leaseTtlSecondsCount;
return typeof sum === "number" && typeof count === "number" && count > 0 ? sum / count : null;
});
const uptimeSeconds = computed(() => {
const startedAt = metrics.value?.processStartTimeSeconds;
return typeof startedAt === "number" ? Math.max(0, Date.now() / 1000 - startedAt) : null;
});
const grpcMethodRates = computed(() => counterMapRates(metrics.value, previousMetrics.value, "grpcMethodRequestsTotal"));
const grpcMethodFailureRates = computed(() => counterMapRates(metrics.value, previousMetrics.value, "grpcMethodFailuresTotal"));
const requestLatencyByType = computed(() => histogramMapAverageMilliseconds(metrics.value, previousMetrics.value, "requestDurationSecondsSumByType", "requestDurationSecondsCountByType"));
const grpcMethodRows = computed(() =>
Object.entries(metrics.value?.grpcMethodRequestsTotal ?? {})
.map(([method, total]) => ({
method,
total,
rate: grpcMethodRates.value[method] ?? null,
failures: metrics.value?.grpcMethodFailuresTotal?.[method] ?? 0,
failureRate: grpcMethodFailureRates.value[method] ?? null,
latencyMs: requestLatencyByType.value[method] ?? null,
}))
.sort((left, right) => (right.rate ?? -1) - (left.rate ?? -1) || right.total - left.total)
.slice(0, 12),
);
const health = computed(() => {
if (!status.value) return "unknown";
if (status.value.alarms.length > 0 || reachable.value === 0 || metrics.value?.hasLeader === 0) return "critical";
if (reachable.value < status.value.members.length || status.value.members.some((member) => member.errors.length > 0) || (metrics.value?.proposalsPending ?? 0) > 0 || (proposalFailureRate.value ?? 0) > 0) return "warning";
return "healthy";
});
function formatBytes(value: number) {
if (!Number.isFinite(value)) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function formatRate(value: number | null, suffix = "/s") {
if (value == null || !Number.isFinite(value)) return "-";
const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(digits)} ${suffix}`;
}
function formatByteRate(value: number | null) {
return value == null || !Number.isFinite(value) ? "-" : `${formatBytes(value)}/s`;
}
function formatMilliseconds(value: number | null) {
if (value == null || !Number.isFinite(value)) return "-";
return `${value.toFixed(value >= 10 ? 1 : 2)} ms`;
}
function formatPercent(value: number | null) {
if (value == null || !Number.isFinite(value)) return "-";
return `${value.toFixed(1)}%`;
}
function formatCount(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString() : "-";
}
function formatDuration(seconds: number | null) {
if (seconds == null || !Number.isFinite(seconds)) return "-";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatCollectedAt(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) ? new Date(value).toLocaleTimeString() : "-";
}
async function load() {
const connectionId = props.connectionId;
if (loading.value && loadingConnectionId === connectionId) return;
const generation = ++loadGeneration;
loadingConnectionId = connectionId;
loading.value = true;
error.value = "";
unsupported.value = false;
try {
const next = await api.etcdStatus(connectionId);
if (generation !== loadGeneration || props.connectionId !== connectionId) return;
previousMetrics.value = status.value?.metrics?.available ? status.value.metrics : null;
status.value = next;
} catch (caught) {
if (generation !== loadGeneration || props.connectionId !== connectionId) return;
const classified = classifyEtcdDashboardError(caught);
if (classified.kind === "unsupported") {
unsupported.value = true;
status.value = null;
refreshSeconds.value = 0;
} else {
error.value = classified.message;
}
} finally {
if (generation === loadGeneration) {
loading.value = false;
loadingConnectionId = null;
}
}
}
function resetTimer() {
if (timer) clearInterval(timer);
timer = refreshSeconds.value > 0 ? setInterval(() => void load(), refreshSeconds.value * 1000) : null;
}
function refresh(): boolean {
void load();
return true;
}
watch(
() => props.connectionId,
() => {
loadGeneration++;
loading.value = false;
loadingConnectionId = null;
status.value = null;
previousMetrics.value = null;
unsupported.value = false;
void load();
},
);
watch(refreshSeconds, resetTimer);
onMounted(() => void load());
onBeforeUnmount(() => {
loadGeneration++;
if (timer) clearInterval(timer);
});
defineExpose({ refresh });
</script>
<template>
<div class="h-full overflow-auto bg-background p-4">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">{{ t("etcd.dashboard.title") }}</h2>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.description") }}</p>
</div>
<div class="flex items-center gap-2">
<select v-model.number="refreshSeconds" class="h-8 rounded-md border bg-background px-2 text-xs">
<option :value="0">{{ t("etcd.dashboard.autoRefreshOff") }}</option>
<option :value="5">5s</option>
<option :value="10">10s</option>
<option :value="30">30s</option>
<option :value="60">60s</option>
</select>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="load">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5" />
{{ t("etcd.dashboard.refresh") }}
</Button>
</div>
</div>
<div v-if="unsupported" class="mx-auto mt-[12vh] max-w-2xl rounded-xl border bg-muted/20 p-6 shadow-sm">
<div class="flex items-start gap-4">
<div class="rounded-full bg-amber-500/10 p-2.5 text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<h3 class="font-semibold">{{ t("etcd.dashboard.agentUpgradeTitle") }}</h3>
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ t("etcd.dashboard.agentUpgradeDescription") }}</p>
<div class="mt-4 rounded-md border bg-background px-3 py-2.5 text-xs leading-5 text-muted-foreground">
{{ t("etcd.dashboard.agentUpgradeSteps") }}
</div>
<Button class="mt-4" size="sm" variant="outline" :disabled="loading" @click="load">
<Loader2 v-if="loading" class="mr-2 h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="mr-2 h-3.5 w-3.5" />
{{ t("etcd.dashboard.retry") }}
</Button>
</div>
</div>
</div>
<div v-else-if="error" class="mb-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div class="flex items-start gap-3">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="min-w-0">
<div class="text-sm font-medium text-destructive">{{ t("etcd.dashboard.loadFailed") }}</div>
<div class="mt-1 break-words text-xs leading-5 text-muted-foreground">{{ error }}</div>
</div>
</div>
</div>
<div v-if="status" class="grid gap-3">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Gauge class="h-4 w-4" /> {{ t("etcd.dashboard.observedHealth") }}</div>
<div class="mt-3 flex items-center gap-2 text-xl font-semibold">
<CheckCircle2 v-if="health === 'healthy'" class="h-5 w-5 text-emerald-500" />
<AlertTriangle v-else class="h-5 w-5 text-amber-500" />
{{ t(`etcd.dashboard.health.${health}`) }}
</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Server class="h-4 w-4" /> {{ t("etcd.dashboard.reachableMembers") }}</div>
<div class="mt-3 text-xl font-semibold">{{ reachable }} / {{ status.members.length }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><KeyRound class="h-4 w-4" /> {{ t("etcd.dashboard.keyCount") }}</div>
<div class="mt-3 text-xl font-semibold">{{ status.keyCount ?? "-" }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Database class="h-4 w-4" /> {{ t("etcd.dashboard.backendSize") }}</div>
<div class="mt-3 text-xl font-semibold">{{ formatBytes(totalDbSize) }}</div>
</div>
<div class="rounded-lg border p-4">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.fragmentation") }}</div>
<div class="mt-3 text-xl font-semibold">{{ (fragmentation * 100).toFixed(1) }}%</div>
</div>
</div>
<div class="flex flex-wrap gap-2 rounded-lg border p-3 text-xs">
<Badge variant="outline">{{ t("etcd.dashboard.cluster") }} {{ status.clusterId ?? "-" }}</Badge>
<Badge variant="outline">{{ t("etcd.dashboard.revision") }} {{ status.revision ?? "-" }}</Badge>
<Badge variant="outline">{{ t("etcd.dashboard.leader") }} {{ status.leaderId ?? "-" }}</Badge>
<Badge v-for="alarm in status.alarms" :key="alarm" variant="destructive">{{ alarm }}</Badge>
<Badge v-if="status.alarms.length === 0" variant="secondary">{{ t("etcd.dashboard.noAlarms") }}</Badge>
</div>
<section v-if="metrics?.available" class="rounded-lg border">
<div class="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
<div>
<div class="flex items-center gap-2 text-sm font-medium">
<Activity class="h-4 w-4 text-emerald-500" />
{{ t("etcd.dashboard.prometheusMetrics") }}
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.prometheusDescription") }}</p>
</div>
<div class="min-w-0 text-right text-xs text-muted-foreground">
<div class="max-w-[420px] truncate font-mono">{{ metrics.sourceUrl }}</div>
<div>{{ t("etcd.dashboard.collectedAt") }} {{ formatCollectedAt(metrics.collectedAtMs) }}</div>
</div>
</div>
<div class="grid gap-px bg-border sm:grid-cols-2 xl:grid-cols-4">
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Activity class="h-4 w-4" /> {{ t("etcd.dashboard.requests") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatRate(requestRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.requestFailureRate") }} {{ formatPercent(requestFailurePercent) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Gauge class="h-4 w-4" /> {{ t("etcd.dashboard.proposals") }}</div>
<div class="mt-2 text-xl font-semibold">{{ proposalLag ?? "-" }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.proposalLag") }} · {{ t("etcd.dashboard.pending") }} {{ formatCount(metrics.proposalsPending) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Database class="h-4 w-4" /> {{ t("etcd.dashboard.storageQuota") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatPercent(quotaPercent) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ formatBytes(metrics.dbSizeMetricBytes ?? Number.NaN) }} / {{ formatBytes(metrics.quotaBackendBytes ?? Number.NaN) }}</div>
</div>
<div class="bg-background p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Clock3 class="h-4 w-4" /> {{ t("etcd.dashboard.uptime") }}</div>
<div class="mt-2 text-xl font-semibold">{{ formatDuration(uptimeSeconds) }}</div>
<div class="mt-1 text-xs text-muted-foreground">etcd {{ metrics.serverVersion ?? "-" }} · {{ metrics.goVersion ?? "-" }}</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Gauge class="h-4 w-4 text-sky-500" /> {{ t("etcd.dashboard.consensusReliability") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.leadership") }}</div>
<div class="mt-2 text-base font-semibold">{{ metrics.isLeader === 1 ? t("etcd.dashboard.leader") : t("etcd.dashboard.follower") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.leaderChanges") }} {{ formatCount(metrics.leaderChangesTotal) }} · {{ t("etcd.dashboard.knownPeers") }} {{ formatCount(metrics.knownPeers) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.proposalState") }}</div>
<div class="mt-2 text-base font-semibold">{{ t("etcd.dashboard.pending") }} {{ formatCount(metrics.proposalsPending) }} · Lag {{ proposalLag ?? "-" }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.failedProposals") }} {{ formatCount(metrics.proposalsFailedTotal) }} · {{ formatRate(proposalFailureRate) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.raftFailures") }}</div>
<div class="mt-2 text-base font-semibold">Heartbeat {{ formatCount(metrics.heartbeatSendFailuresTotal) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Read index {{ formatCount(metrics.readIndexesFailedTotal) }} · Slow {{ formatCount(metrics.slowReadIndexesTotal) }}</div>
</div>
<div class="rounded-md border bg-muted/10 p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.revisionState") }}</div>
<div class="mt-2 text-base font-semibold">{{ formatCount(metrics.mvccCurrentRevision) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.compactRevision") }} {{ formatCount(metrics.mvccCompactRevision) }} · Slow apply {{ formatCount(metrics.slowApplyTotal) }}</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Activity class="h-4 w-4 text-violet-500" /> {{ t("etcd.dashboard.requestLoad") }}</div>
<div class="mt-3 grid gap-3 xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]">
<div class="overflow-auto rounded-md border">
<table class="w-full min-w-[620px] text-left text-xs">
<thead class="bg-muted/60 text-muted-foreground">
<tr>
<th class="px-3 py-2">{{ t("etcd.dashboard.grpcMethod") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.total") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.rate") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.failures") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.avgLatency") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in grpcMethodRows" :key="row.method" class="border-t">
<td class="px-3 py-2 font-medium">{{ row.method }}</td>
<td class="px-3 py-2 tabular-nums">{{ formatCount(row.total) }}</td>
<td class="px-3 py-2 tabular-nums">{{ formatRate(row.rate) }}</td>
<td class="px-3 py-2 tabular-nums">
{{ formatCount(row.failures) }} <span v-if="row.failureRate != null" class="text-muted-foreground">({{ formatRate(row.failureRate) }})</span>
</td>
<td class="px-3 py-2 tabular-nums">{{ formatMilliseconds(row.latencyMs) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Range</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccRangeRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccRangeTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Put</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccPutRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccPutTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Delete</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccDeleteRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccDeleteTotal) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Txn</div>
<div class="mt-1 font-semibold">{{ formatRate(mvccTxnRate) }}</div>
<div class="text-xs text-muted-foreground">{{ formatCount(metrics.mvccTxnTotal) }}</div>
</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><HardDrive class="h-4 w-4 text-amber-500" /> {{ t("etcd.dashboard.storageDisk") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.diskLatency") }}</div>
<div class="mt-2 font-semibold">WAL fsync {{ formatMilliseconds(walFsyncMs) }}</div>
<div class="mt-1 text-xs text-muted-foreground">WAL write {{ formatMilliseconds(walWriteMs) }} · {{ formatByteRate(walWriteRate) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">Backend</div>
<div class="mt-2 font-semibold">Commit {{ formatMilliseconds(backendCommitMs) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Snapshot {{ formatMilliseconds(backendSnapshotMs) }} · Defrag {{ formatMilliseconds(backendDefragMs) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.databaseState") }}</div>
<div class="mt-2 font-semibold">{{ formatBytes(metrics.dbSizeInUseMetricBytes ?? Number.NaN) }} {{ t("etcd.dashboard.inUse") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("etcd.dashboard.openReadTransactions") }} {{ formatCount(metrics.openReadTransactions) }} · Put bytes {{ formatBytes(metrics.mvccTotalPutSizeBytes ?? Number.NaN) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.backgroundTasks") }}</div>
<div class="mt-2 font-semibold">Defrag {{ formatCount(metrics.diskDefragInflight) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Snapshot apply {{ formatCount(metrics.snapshotApplyInProgress) }}</div>
</div>
</div>
</div>
<div class="grid border-t xl:grid-cols-2 xl:divide-x">
<div class="px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Eye class="h-4 w-4 text-cyan-500" /> {{ t("etcd.dashboard.watchState") }}</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.watchers") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccWatcherTotal) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.watchStreams") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccWatchStreamTotal) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.events") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(mvccEventRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.slowPending") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatCount(metrics.mvccSlowWatcherTotal) }} / {{ formatCount(metrics.mvccPendingEventsTotal) }}</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3 xl:border-t-0">
<div class="flex items-center gap-2 text-sm font-medium"><Radio class="h-4 w-4 text-emerald-500" /> {{ t("etcd.dashboard.leaseState") }}</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.granted") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseGrantedRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.renewed") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseRenewedRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.revokedExpired") }}</div>
<div class="mt-1 text-base font-semibold">{{ formatRate(leaseRevokedRate) }} / {{ formatRate(leaseExpiredRate) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("etcd.dashboard.averageTtl") }}</div>
<div class="mt-1 text-base font-semibold">{{ averageLeaseTtl == null ? "-" : `${averageLeaseTtl.toFixed(1)}s` }}</div>
</div>
</div>
</div>
</div>
<div class="border-t px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><Cpu class="h-4 w-4 text-rose-500" /> {{ t("etcd.dashboard.runtimeResources") }}</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.memory") }}</div>
<div class="mt-2 font-semibold">RSS {{ formatBytes(metrics.residentMemoryBytes ?? Number.NaN) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Heap {{ formatBytes(metrics.goHeapAllocBytes ?? Number.NaN) }} / {{ formatBytes(metrics.goHeapSysBytes ?? Number.NaN) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.runtime") }}</div>
<div class="mt-2 font-semibold">{{ formatCount(metrics.goroutines) }} goroutines</div>
<div class="mt-1 text-xs text-muted-foreground">{{ formatCount(metrics.goThreads) }} threads · GOMAXPROCS {{ formatCount(metrics.goMaxProcs) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.processResources") }}</div>
<div class="mt-2 font-semibold">CPU {{ formatPercent(cpuPercent) }}</div>
<div class="mt-1 text-xs text-muted-foreground">FD {{ formatCount(metrics.openFds) }} / {{ formatCount(metrics.maxFds) }} · {{ formatPercent(fdPercent) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground"><Network class="h-3.5 w-3.5" /> {{ t("etcd.dashboard.networkTraffic") }}</div>
<div class="mt-2 font-semibold">↓ {{ formatByteRate(clientReceivedRate) }} · ↑ {{ formatByteRate(clientSentRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">Peer ↓ {{ formatByteRate(peerReceivedRate) }} · ↑ {{ formatByteRate(peerSentRate) }}</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs text-muted-foreground">{{ t("etcd.dashboard.processNetworkGc") }}</div>
<div class="mt-2 font-semibold">↓ {{ formatByteRate(processReceivedRate) }} · ↑ {{ formatByteRate(processTransmittedRate) }}</div>
<div class="mt-1 text-xs text-muted-foreground">GC {{ formatMilliseconds(goGcMs) }} · {{ formatCount(metrics.goHeapObjects) }} objects</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 border-t px-4 py-3 text-xs">
<Badge variant="outline">etcd {{ metrics.serverVersion ?? "-" }}</Badge>
<Badge variant="outline">Cluster {{ metrics.clusterVersion ?? "-" }}</Badge>
<Badge variant="outline">Auth revision {{ formatCount(metrics.authRevision) }}</Badge>
<Badge variant="outline">Keys {{ formatCount(metrics.mvccKeysTotal) }}</Badge>
<Badge variant="outline">Health {{ formatCount(metrics.healthSuccessTotal) }} / {{ formatCount(metrics.healthFailuresTotal) }}</Badge>
<span v-if="requestRate == null" class="text-muted-foreground">{{ t("etcd.dashboard.rateNeedsRefresh") }}</span>
</div>
</section>
<section v-else-if="metrics && !metrics.available" class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<div class="flex items-start gap-3">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("etcd.dashboard.metricsUnavailable") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("etcd.dashboard.metricsUnavailableHint") }}</p>
<p v-if="metrics.error" class="mt-2 break-words font-mono text-xs text-muted-foreground">{{ metrics.error }}</p>
</div>
</div>
</section>
<div class="overflow-auto rounded-lg border">
<table class="w-full min-w-[1050px] text-left text-sm">
<thead class="bg-muted/70 text-xs text-muted-foreground">
<tr>
<th class="px-3 py-2">{{ t("etcd.dashboard.endpointMember") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.role") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.version") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.revision") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.raftTermApplied") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.dbSizeInUse") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.latency") }}</th>
<th class="px-3 py-2">{{ t("etcd.dashboard.status") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="member in status.members" :key="member.endpoint" class="border-t">
<td class="px-3 py-2">
<div class="font-medium">{{ member.name || member.endpoint }}</div>
<div class="max-w-72 truncate font-mono text-xs text-muted-foreground">{{ member.endpoint }} · {{ member.memberId || "-" }}</div>
</td>
<td class="px-3 py-2">{{ member.learner ? t("etcd.dashboard.learner") : member.memberId === status.leaderId ? t("etcd.dashboard.leader") : t("etcd.dashboard.follower") }}</td>
<td class="px-3 py-2">{{ member.version || "-" }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ member.revision || "-" }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ member.raftTerm || "-" }} / {{ member.raftAppliedIndex || "-" }}</td>
<td class="px-3 py-2">{{ formatBytes(Number(member.dbSize || 0)) }} / {{ formatBytes(Number(member.dbSizeInUse || 0)) }}</td>
<td class="px-3 py-2">{{ member.latencyMs == null ? "-" : `${member.latencyMs} ms` }}</td>
<td class="px-3 py-2">
<Badge :variant="member.reachable && member.errors.length === 0 ? 'secondary' : 'destructive'">
{{ member.reachable ? member.errors[0] || t("etcd.dashboard.reachable") : member.errors[0] || t("etcd.dashboard.unreachable") }}
</Badge>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="loading" class="flex h-64 items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("etcd.dashboard.loading") }}
</div>
</div>
</template>
@@ -1,12 +1,63 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { ArrowRightLeft, Download, KeyRound, Loader2, Search, Upload } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
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";
type SearchScope = "key" | "value" | "all";
type TransferOperation = "create" | "update" | "delete" | "unchanged" | "skipped" | "applied";
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 SearchResult {
id: string;
displayKey: string;
keyIdentity: string;
summary: api.KvKeySummary;
selected: boolean;
}
interface TransferRow {
id: string;
displayKey: string;
source?: EtcdBundleEntry;
target?: api.KvGetResponse;
operation: TransferOperation;
reason?: string;
selected: boolean;
}
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);
@@ -14,13 +65,102 @@ const ttlCapabilityRefreshIntervalMs = 5000;
let ttlCapabilityRequest = 0;
let ttlCapabilityInFlightConnection: string | null = null;
let ttlCapabilityRefreshTimer: ReturnType<typeof setInterval> | null = null;
const mode = ref<WorkbenchMode>("keys");
const keyBytesByDisplay = new Map<string, Map<string, api.KvValue>>();
const fileInput = ref<HTMLInputElement>();
const searchQuery = ref("");
const searchPrefix = ref("");
const searchScope = ref<SearchScope>("all");
const searchResults = ref<SearchResult[]>([]);
const searchRunning = ref(false);
const searchScanned = ref(0);
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 mirrorDeletes = ref(false);
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 mirrorDeleteAvailable = computed(() => transferBundle.value?.scopeKind === "prefix");
const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && !["unchanged", "skipped", "applied"].includes(row.operation)));
function decodeBase64(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function displayKey(key: api.KvValue, fallback = ""): string {
if (key.encoding === "utf8") return key.data;
try {
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(key.data));
if (decoded === fallback) return decoded;
} 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>();
bytesByIdentity.set(identity, bytes);
keyBytesByDisplay.set(display, bytesByIdentity);
return identity;
}
function rememberSummary(summary: api.KvKeySummary): api.KvKeySummary {
const bytes = keyValue(summary);
const shown = displayKey(bytes, summary.key);
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 = {
listPrefix: api.etcdListPrefix,
get: api.etcdGet,
getMetadata: (connectionId: string, key: string) => api.etcdGet(connectionId, key, { metadataOnly: true }),
put: (connectionId: string, key: string, value: api.KvValue, options?: api.KvPutOptions | null) => api.etcdPut(connectionId, key, value, options),
deleteKey: api.etcdDelete,
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, result.key ?? key);
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(() => ({
@@ -38,19 +178,38 @@ const labels = computed(() => ({
deleteTitle: t("etcd.deleteTitle"),
keyPlaceholder: t("etcd.keyPlaceholder"),
keyRequired: t("etcd.keyRequired"),
ttl: t("etcd.ttl"),
ttlPlaceholder: t("etcd.ttlPlaceholder"),
ttlInvalid: t("etcd.ttlInvalid"),
ttlUnavailable: t("etcd.ttlUnavailable"),
saved: t("etcd.saved"),
deleted: t("etcd.deleted"),
base64Readonly: t("etcd.base64Readonly"),
rename: t("etcd.rename"),
clone: t("etcd.clone"),
copyKey: t("etcd.copyKey"),
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"),
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 {
@@ -59,8 +218,9 @@ async function refreshTtlCapability() {
supportsTtl.value = supported;
ttlCapabilityKnown.value = true;
} catch {
// A transient connection failure is not evidence that the installed Agent lacks TTL support.
// Keep the last confirmed capability and let the interval or an explicit refresh retry.
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;
}
@@ -78,14 +238,13 @@ function stopTtlCapabilityRefresh() {
function startTtlCapabilityRefresh() {
stopTtlCapabilityRefresh();
void refreshTtlCapability();
ttlCapabilityRefreshTimer = setInterval(() => {
void refreshTtlCapability();
}, ttlCapabilityRefreshIntervalMs);
ttlCapabilityRefreshTimer = setInterval(() => void refreshTtlCapability(), ttlCapabilityRefreshIntervalMs);
}
watch(
() => props.connectionId,
() => {
keyBytesByDisplay.clear();
supportsTtl.value = false;
ttlCapabilityKnown.value = false;
startTtlCapabilityRefresh();
@@ -95,17 +254,477 @@ watch(
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);
}
async function scanConnection(connectionId: string, prefix: string, onPage?: (scanned: number) => void): Promise<{ entries: api.KvKeySummary[]; revision: string | null }> {
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;
onPage?.(entries.length);
if (entries.length >= 50_000) throw new Error("Safety limit reached: scan is limited to 50,000 keys.");
if (searchCancelled) break;
} while (continuation);
return { entries, revision };
}
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 downloadBundle(bundle: EtcdBundle, filename: string) {
const url = URL.createObjectURL(new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
async function exportAll() {
try {
searchCancelled = false;
const scan = await scanConnection(props.connectionId, "");
downloadBundle(bundleFromSummaries(scan.entries, "", scan.revision), `dbx-etcd-${Date.now()}.json`);
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), entry.key), request));
revision = scan.revision;
}
const bundle = bundleFromSummaries(entries, request.path, revision, request.kind);
downloadBundle(bundle, `dbx-etcd-${kvExportFilenameStem(request.path)}-${Date.now()}.json`);
toast(t("etcd.exported", { count: bundle.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;
mirrorDeletes.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 || "";
mirrorDeletes.value = false;
transferRows.value = [];
transferError.value = "";
transferOpen.value = true;
transferLoading.value = true;
try {
searchCancelled = false;
const scan = await scanConnection(props.connectionId, "");
transferBundle.value = bundleFromSummaries(scan.entries, "", scan.revision);
if (targetConnectionId.value) await previewTransfer();
} catch (error) {
transferError.value = error instanceof Error ? error.message : String(error);
} finally {
transferLoading.value = false;
}
}
async function previewTransfer() {
const bundle = transferBundle.value;
const targetId = targetConnectionId.value;
const includeMirrorDeletes = mirrorDeletes.value && bundle?.scopeKind === "prefix";
if (!bundle || !targetId) return;
const generation = ++transferPreviewGeneration;
transferLoading.value = true;
transferError.value = "";
const rows: TransferRow[] = [];
try {
for (const source of bundle.entries) {
const shown = displayKey(source.key);
if (normalizedLease(source.metadata) !== "0") {
rows.push({ id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, operation: "skipped", reason: "Leased keys are skipped by default.", selected: false });
continue;
}
const target = await api.etcdGet(targetId, shown, { keyBytes: source.key });
if (generation !== transferPreviewGeneration) return;
const operation: TransferOperation = !target.found ? "create" : valuesEqual(source.value, target.value) ? "unchanged" : "update";
rows.push({ id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, target, operation, selected: operation === "create" || operation === "update" });
}
if (includeMirrorDeletes) {
searchCancelled = false;
const mirrorScope: KvExportScopeRequest = {
path: bundle.prefix || "",
kind: "prefix",
};
const targetScan = await scanConnection(targetId, mirrorScope.path);
if (generation !== transferPreviewGeneration) return;
const sourceKeys = new Set(bundle.entries.map((entry) => kvValueByteIdentity(entry.key)));
for (const target of targetScan.entries) {
const bytes = keyValue(target);
const shown = displayKey(bytes, target.key);
if (!isKeyInKvExportScope(shown, mirrorScope) || sourceKeys.has(kvValueByteIdentity(bytes))) continue;
rows.push({
id: `delete:${kvValueByteIdentity(bytes)}`,
displayKey: shown,
target: { found: true, key: target.key, keyBytes: bytes, value: target.value, metadata: target },
operation: "delete",
reason: "Mirror mode: key is absent from source.",
selected: true,
});
}
}
if (generation !== transferPreviewGeneration) return;
transferRows.value = rows;
} catch (error) {
if (generation !== transferPreviewGeneration) return;
transferError.value = error instanceof Error ? error.message : String(error);
} finally {
if (generation === transferPreviewGeneration) transferLoading.value = false;
}
}
async function applyTransfer() {
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.operation === "delete") {
await api.etcdDelete(targetId, row.displayKey, {
keyBytes: row.target?.keyBytes,
expectedModRevision: row.target?.metadata?.modRevision == null ? undefined : String(row.target.metadata.modRevision),
});
} else if (row.source) {
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,
});
} else {
continue;
}
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 = [];
if (targetConnectionId.value) void previewTransfer();
}
function onMirrorDeleteChange() {
transferPreviewGeneration++;
void previewTransfer();
}
async function runSearch() {
const query = searchQuery.value;
if (!query) return;
searchRunning.value = true;
searchCancelled = false;
searchScanned.value = 0;
searchError.value = "";
searchResults.value = [];
try {
const scan = await scanConnection(props.connectionId, searchPrefix.value, (count) => (searchScanned.value = count));
const normalized = query.toLocaleLowerCase();
searchResults.value = scan.entries
.filter((entry) => {
const shown = displayKey(keyValue(entry), entry.key).toLocaleLowerCase();
const value = entry.value?.data.toLocaleLowerCase() || "";
return (searchScope.value !== "value" && shown.includes(normalized)) || (searchScope.value !== "key" && value.includes(normalized));
})
.slice(0, 1000)
.map((summary) => {
const bytes = keyValue(summary);
const shown = displayKey(bytes, summary.key);
const identity = rememberKeyBytes(shown, bytes);
return { id: `${identity}:${summary.modRevision || ""}`, displayKey: shown, keyIdentity: identity, summary: { ...summary, key: shown, keyBytes: bytes, keyIdentity: identity }, 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) });
}
function exportSearchResults() {
const selected = searchResults.value.filter((result) => result.selected).map((result) => result.summary);
downloadBundle(bundleFromSummaries(selected, searchPrefix.value, null, "selection"), `dbx-etcd-search-${Date.now()}.json`);
}
function focusSearch(): boolean {
return browserRef.value?.focusSearch() ?? false;
if (mode.value === "keys") return browserRef.value?.focusSearch() ?? false;
mode.value = "search";
return true;
}
function refresh(): boolean {
return browserRef.value?.refresh() ?? false;
if (mode.value === "keys") return browserRef.value?.refresh() ?? false;
if (mode.value === "search") void runSearch();
return true;
}
defineExpose({ focusSearch, refresh });
</script>
<template>
<KvKeyBrowser ref="browserRef" :connection-id="props.connectionId" :api="etcdApi" :labels="labels" :supports-ttl="supportsTtl" :ttl-capability-known="ttlCapabilityKnown" @refresh-requested="refreshTtlCapability" />
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<div class="flex rounded-md border p-0.5">
<Button size="sm" :variant="mode === 'keys' ? 'secondary' : 'ghost'" class="h-7 gap-1.5" @click="mode = 'keys'"><KeyRound class="h-3.5 w-3.5" /> Keys</Button>
<Button size="sm" :variant="mode === 'search' ? 'secondary' : 'ghost'" class="h-7 gap-1.5" @click="mode = 'search'"><Search class="h-3.5 w-3.5" /> {{ t("etcd.globalSearch") }}</Button>
</div>
<div class="flex-1" />
<Badge v-if="readOnly" variant="outline">{{ t("connection.readOnly") }}</Badge>
<Button size="sm" variant="outline" class="h-8 gap-1.5" @click="exportAll"><Download class="h-3.5 w-3.5" /> {{ t("etcd.export") }}</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"
@refresh-requested="refreshTtlCapability"
/>
<div v-else-if="mode === 'search'" class="flex min-h-0 flex-1 flex-col">
<div class="grid shrink-0 gap-2 border-b p-3 md:grid-cols-[minmax(160px,240px)_140px_1fr_auto]">
<Input v-model="searchPrefix" :placeholder="t('etcd.searchPrefix')" @keyup.enter="runSearch" />
<Select v-model="searchScope">
<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')" @keyup.enter="runSearch" />
<div class="flex gap-2">
<Button :disabled="searchRunning || !searchQuery" @click="runSearch"><Loader2 v-if="searchRunning" class="mr-2 h-4 w-4 animate-spin" />{{ t("etcd.globalSearch") }}</Button>
<Button v-if="searchRunning" variant="outline" @click="cancelSearch">{{ t("common.cancel") }}</Button>
</div>
</div>
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2 text-xs text-muted-foreground">
<span>{{ t("etcd.searchProgress", { scanned: searchScanned, matched: searchResults.length }) }}</span>
<Button size="sm" variant="outline" class="h-7 gap-1.5" :disabled="searchResults.length === 0" @click="exportSearchResults"><Download class="h-3.5 w-3.5" /> {{ t("etcd.exportResults") }}</Button>
</div>
<div v-if="searchError" class="border-b px-3 py-2 text-sm text-destructive">{{ searchError }}</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"></th>
<th class="px-3 py-2">Key</th>
<th class="px-3 py-2">Value</th>
<th class="px-3 py-2">Revision</th>
</tr>
</thead>
<tbody>
<tr v-for="result in searchResults" :key="result.id" class="cursor-pointer border-b hover:bg-accent/50" @dblclick="openSearchResult(result)">
<td class="px-3 py-2"><input v-model="result.selected" type="checkbox" @click.stop /></td>
<td class="max-w-md truncate px-3 py-2 font-mono text-xs text-primary" @click="openSearchResult(result)">{{ result.displayKey }}</td>
<td class="max-w-xl truncate px-3 py-2 font-mono text-xs">{{ result.summary.value?.data || "" }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ result.summary.modRevision || "-" }}</td>
</tr>
</tbody>
</table>
<div v-if="!searchRunning && searchResults.length === 0" class="flex h-52 items-center justify-center text-sm text-muted-foreground">{{ t("etcd.empty") }}</div>
</div>
</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 border-b px-5 py-4">
<DialogTitle>{{ transferMode === "sync" ? t("etcd.syncPreview") : t("etcd.importPreview") }}</DialogTitle>
<div class="mt-3 grid gap-3 md:grid-cols-[1fr_auto]">
<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.filter((connection) => transferMode === 'import' || connection.id !== props.connectionId)" :key="connection.id" :value="connection.id">
{{ connection.name }}
</SelectItem>
</SelectContent>
</Select>
<label class="inline-flex items-center gap-2 rounded-md border px-3 text-sm" :class="{ 'cursor-not-allowed opacity-50': !mirrorDeleteAvailable }" :title="mirrorDeleteAvailable ? undefined : 'Mirror delete is available only for complete prefix bundles.'">
<input v-model="mirrorDeletes" type="checkbox" :disabled="!mirrorDeleteAvailable || transferLoading || transferApplying" @change="onMirrorDeleteChange" />
{{ t("etcd.mirrorDelete") }}
</label>
</div>
</DialogHeader>
<div v-if="transferError" class="border-b px-5 py-2 text-sm text-destructive">{{ transferError }}</div>
<div v-if="mirrorDeletes" class="border-b bg-destructive/5 px-5 py-2 text-xs text-destructive">{{ t("etcd.mirrorDeleteWarning") }}</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 overflow-auto">
<div v-if="transferLoading" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />{{ t("etcd.preparingPreview") }}</div>
<table v-else 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"></th>
<th class="px-3 py-2">Key</th>
<th class="px-3 py-2">{{ t("etcd.operation") }}</th>
<th class="px-3 py-2">{{ t("etcd.reason") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in transferRows" :key="row.id" class="border-b">
<td class="px-3 py-2"><input v-model="row.selected" type="checkbox" :disabled="row.operation === 'unchanged' || row.operation === 'skipped' || row.operation === 'applied'" /></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="row.operation === 'delete' ? 'destructive' : 'outline'">{{ row.operation }}</Badge>
</td>
<td class="px-3 py-2 text-xs text-muted-foreground">{{ row.reason || "-" }}</td>
</tr>
</tbody>
</table>
</div>
<DialogFooter class="shrink-0 border-t px-5 py-4">
<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 :variant="mirrorDeletes ? 'destructive' : 'default'" :disabled="targetReadOnly || transferLoading || transferApplying || 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>
</div>
</template>
@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dashboardSource = readFileSync(new URL("../EtcdDashboard.vue", import.meta.url), "utf8");
describe("EtcdDashboard refresh concurrency", () => {
it("skips overlapping refreshes for the same connection", () => {
expect(dashboardSource).toContain("if (loading.value && loadingConnectionId === connectionId) return");
});
it("discards responses from an obsolete load generation", () => {
expect(dashboardSource).toContain("const generation = ++loadGeneration");
expect(dashboardSource).toContain("if (generation !== loadGeneration || props.connectionId !== connectionId) return");
});
});
@@ -5,6 +5,15 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue";
const backend = vi.hoisted(() => ({
etcdSupportsTtl: vi.fn(),
etcdListPrefix: vi.fn(),
etcdGet: vi.fn(),
etcdPut: vi.fn(),
etcdDelete: vi.fn(),
etcdRename: vi.fn(),
etcdHistory: vi.fn(),
etcdGetCalls: [] as unknown[][],
capturedKvBrowserApi: null as any,
selectKeyCalls: [] as unknown[],
}));
vi.mock("vue-i18n", () => ({
@@ -12,24 +21,41 @@ vi.mock("vue-i18n", () => ({
}));
vi.mock("@/lib/backend/api", () => ({
etcdListPrefix: vi.fn(),
etcdGet: vi.fn(),
etcdPut: vi.fn(),
etcdDelete: vi.fn(),
etcdListPrefix: backend.etcdListPrefix,
etcdGet: backend.etcdGet,
etcdPut: backend.etcdPut,
etcdDelete: backend.etcdDelete,
etcdRename: backend.etcdRename,
etcdHistory: backend.etcdHistory,
etcdSupportsTtl: backend.etcdSupportsTtl,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
connections: [],
getConfig: () => undefined,
}),
}));
vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({
default: defineComponent({
name: "KvKeyBrowserStub",
inheritAttrs: false,
props: {
api: { type: Object, required: true },
connectionId: { type: String, required: true },
supportsTtl: { type: Boolean, default: false },
ttlCapabilityKnown: { type: Boolean, default: true },
},
emits: ["refreshRequested"],
setup(props, { emit, expose }) {
backend.capturedKvBrowserApi = props.api;
expose({
focusSearch: () => true,
selectKey: (route: unknown) => {
backend.selectKeyCalls.push(route);
return Promise.resolve();
},
refresh: () => {
emit("refreshRequested");
return true;
@@ -73,6 +99,16 @@ async function mountBrowser() {
beforeEach(() => {
vi.useFakeTimers();
backend.etcdSupportsTtl.mockReset();
backend.etcdListPrefix.mockReset();
backend.etcdGet.mockReset();
backend.etcdPut.mockReset();
backend.etcdDelete.mockReset();
backend.etcdRename.mockReset();
backend.etcdHistory.mockReset();
backend.etcdListPrefix.mockResolvedValue({ keys: [], continuation: null, revision: "1" });
backend.etcdGetCalls.length = 0;
backend.capturedKvBrowserApi = null;
backend.selectKeyCalls.length = 0;
});
afterEach(() => {
@@ -129,3 +165,115 @@ describe("EtcdKeyBrowser TTL capability recovery", () => {
expect(browser.dataset.capabilityKnown).toBe("true");
});
});
describe("EtcdKeyBrowser byte-identity routing", () => {
it("keeps colliding display keys on distinct keyBytes routes", async () => {
backend.etcdListPrefix.mockResolvedValue({
keys: [
{
key: "/w==",
keyBytes: { encoding: "base64", data: "/w==" },
value: { encoding: "utf8", data: "binary" },
modRevision: "1",
},
{
key: "[base64:/w==]",
keyBytes: { encoding: "utf8", data: "[base64:/w==]" },
value: { encoding: "utf8", data: "utf8" },
modRevision: "2",
},
],
continuation: null,
revision: "7",
});
backend.etcdGet.mockImplementation(async (_connectionId, key, options) => ({
found: true,
key,
keyBytes: options?.keyBytes ?? null,
value: { encoding: "utf8", data: "value" },
metadata: { modRevision: "9" },
}));
backend.etcdPut.mockResolvedValue({ revision: 10, key: "[base64:/w==]" });
backend.etcdDelete.mockResolvedValue({ deleted: 1 });
backend.etcdRename.mockResolvedValue({ renamed: true, revision: "11" });
backend.etcdHistory.mockResolvedValue({ events: [] });
await mountBrowser();
expect(backend.capturedKvBrowserApi).toBeTruthy();
const { keys } = await backend.capturedKvBrowserApi.listPrefix("etcd-1", "", 20);
expect(keys).toHaveLength(2);
const [binaryKey, utf8Key] = keys;
expect(binaryKey.key).toBe("[base64:/w==]");
expect(utf8Key.key).toBe("[base64:/w==]");
expect(binaryKey.keyIdentity).not.toBe(utf8Key.keyIdentity);
await backend.capturedKvBrowserApi.get("etcd-1", binaryKey.key, { keyBytes: binaryKey.keyBytes });
await backend.capturedKvBrowserApi.put("etcd-1", binaryKey.key, { encoding: "utf8", data: "x" }, { keyBytes: binaryKey.keyBytes });
await backend.capturedKvBrowserApi.deleteKey("etcd-1", utf8Key.key, { keyBytes: utf8Key.keyBytes });
await backend.capturedKvBrowserApi.rename?.("etcd-1", { key: binaryKey.key, keyBytes: binaryKey.keyBytes, newKey: "renamed", expectedModRevision: "9" });
await backend.capturedKvBrowserApi.history?.("etcd-1", { key: utf8Key.key, keyBytes: utf8Key.keyBytes, limit: 10 });
await backend.capturedKvBrowserApi.exportScope?.("etcd-1", { path: binaryKey.key, kind: "key", keyBytes: binaryKey.keyBytes });
expect(backend.etcdGet).toHaveBeenCalledWith("etcd-1", "[base64:/w==]", expect.objectContaining({ keyBytes: binaryKey.keyBytes }));
expect(backend.etcdPut).toHaveBeenCalledWith("etcd-1", "[base64:/w==]", { encoding: "utf8", data: "x" }, expect.objectContaining({ keyBytes: binaryKey.keyBytes }));
expect(backend.etcdDelete).toHaveBeenCalledWith("etcd-1", "[base64:/w==]", expect.objectContaining({ keyBytes: utf8Key.keyBytes }));
expect(backend.etcdRename).toHaveBeenCalledWith("etcd-1", expect.objectContaining({ key: "[base64:/w==]", keyBytes: binaryKey.keyBytes }));
expect(backend.etcdHistory).toHaveBeenCalledWith("etcd-1", expect.objectContaining({ key: "[base64:/w==]", keyBytes: utf8Key.keyBytes }));
expect(backend.etcdGet.mock.calls.at(-1)?.[2]).toEqual(expect.objectContaining({ keyBytes: binaryKey.keyBytes }));
});
it("opens search results with the selected keyBytes", async () => {
backend.etcdListPrefix.mockResolvedValue({
keys: [
{
key: "/w==",
keyBytes: { encoding: "base64", data: "/w==" },
value: { encoding: "utf8", data: "match" },
modRevision: "1",
},
{
key: "[base64:/w==]",
keyBytes: { encoding: "utf8", data: "[base64:/w==]" },
value: { encoding: "utf8", data: "match" },
modRevision: "2",
},
],
continuation: null,
revision: "7",
});
const root = document.createElement("div");
document.body.appendChild(root);
const app = createApp(EtcdKeyBrowser, { connectionId: "etcd-1" });
app.mount(root);
await flushUi();
const searchButton = [...root.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.globalSearch")) as HTMLButtonElement | undefined;
expect(searchButton).toBeTruthy();
searchButton!.click();
await flushUi();
const input = root.querySelector('input[placeholder="etcd.searchPlaceholder"]') as HTMLInputElement | null;
expect(input).toBeTruthy();
input.value = "match";
input.dispatchEvent(new Event("input"));
await flushUi();
const searchSubmit = [...root.querySelectorAll("button")].filter((button) => button.textContent?.includes("etcd.globalSearch")).at(-1) as HTMLButtonElement | undefined;
searchSubmit?.click();
await flushUi();
const rows = [...root.querySelectorAll("tbody tr")];
expect(rows).toHaveLength(2);
rows[0].dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
rows[1].dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await flushUi();
expect(backend.selectKeyCalls).toHaveLength(2);
expect(backend.selectKeyCalls[0]).toEqual(expect.objectContaining({ keyBytes: { encoding: "base64", data: "/w==" } }));
expect(backend.selectKeyCalls[1]).toEqual(expect.objectContaining({ keyBytes: { encoding: "utf8", data: "[base64:/w==]" } }));
app.unmount();
root.remove();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { json } from "@codemirror/lang-json";
import { sql } from "@codemirror/lang-sql";
import { yaml } from "@codemirror/lang-yaml";
import { xml } from "@codemirror/lang-xml";
import { StreamLanguage } from "@codemirror/language";
import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile";
import { nginx } from "@codemirror/legacy-modes/mode/nginx";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { KvValueFormat } from "@/lib/kv/kvValueFormat";
const props = withDefaults(
defineProps<{
modelValue: string;
format?: KvValueFormat;
readOnly?: boolean;
}>(),
{
format: "text",
readOnly: false,
},
);
const emit = defineEmits<{ "update:modelValue": [value: string] }>();
const host = ref<HTMLElement>();
const languageCompartment = new Compartment();
const readOnlyCompartment = new Compartment();
let view: EditorView | null = null;
function languageExtension(format: KvValueFormat) {
if (format === "json") return json();
if (format === "yaml" || format === "kubernetes") return yaml();
if (format === "xml") return xml();
if (format === "sql") return sql();
if (format === "properties") return StreamLanguage.define(properties);
if (format === "shell") return StreamLanguage.define(shell);
if (format === "dockerfile") return StreamLanguage.define(dockerFile);
if (format === "nginx") return StreamLanguage.define(nginx);
return [];
}
function readOnlyExtensions(readOnly: boolean) {
return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)];
}
onMounted(() => {
if (!host.value) return;
view = new EditorView({
parent: host.value,
state: EditorState.create({
doc: props.modelValue,
extensions: [
basicSetup,
languageCompartment.of(languageExtension(props.format)),
readOnlyCompartment.of(readOnlyExtensions(props.readOnly)),
EditorView.lineWrapping,
EditorView.theme({
"&": { height: "100%", fontSize: "13px", backgroundColor: "transparent" },
".cm-scroller": { fontFamily: "var(--dbx-editor-font-family, ui-monospace)", overflow: "auto" },
".cm-content": { minHeight: "13rem" },
"&.cm-focused": { outline: "none" },
}),
EditorView.updateListener.of((update) => {
if (update.docChanged) emit("update:modelValue", update.state.doc.toString());
}),
],
}),
});
});
watch(
() => props.modelValue,
(value) => {
if (!view || view.state.doc.toString() === value) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
},
);
watch(
() => props.format,
(format) => view?.dispatch({ effects: languageCompartment.reconfigure(languageExtension(format)) }),
);
watch(
() => props.readOnly,
(readOnly) => view?.dispatch({ effects: readOnlyCompartment.reconfigure(readOnlyExtensions(readOnly)) }),
);
onBeforeUnmount(() => {
view?.destroy();
view = null;
});
</script>
<template>
<div ref="host" class="min-h-52 overflow-hidden rounded-md border border-input bg-background" />
</template>
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
const etcdBrowserSource = readFileSync(new URL("../../etcd/EtcdKeyBrowser.vue", import.meta.url), "utf8");
describe("KvKeyBrowser node export", () => {
it("shows export for both value keys and virtual directories", () => {
expect(browserSource).toContain("if (props.api.exportScope || nodeHasValue(node))");
expect(browserSource).toContain('kind: nodeIsExpandable(node) ? "prefix" : "key"');
expect(browserSource).toContain("await props.api.exportScope(props.connectionId, request)");
});
it("delegates etcd directory export to a fixed-revision recursive scan", () => {
expect(etcdBrowserSource).toContain("exportScope: exportEtcdNodeScope");
expect(etcdBrowserSource).toContain("const scan = await scanConnection(connectionId, request.path)");
expect(etcdBrowserSource).toContain("isKeyInKvExportScope(displayKey(keyValue(entry), entry.key), request)");
expect(etcdBrowserSource).toContain("const missingValue = entries.find((entry) => !entry.value)");
});
it("keeps mirror deletes inside the exported directory and compares canonical Key bytes", () => {
expect(etcdBrowserSource).toContain("if (!isKeyInKvExportScope(shown, mirrorScope)");
expect(etcdBrowserSource).toContain("sourceKeys.has(kvValueByteIdentity(bytes))");
expect(etcdBrowserSource).toContain("id: `source:${kvValueByteIdentity(source.key)}`");
expect(etcdBrowserSource).not.toContain("id: source.key.data");
expect(etcdBrowserSource).toContain('const mirrorDeleteAvailable = computed(() => transferBundle.value?.scopeKind === "prefix")');
expect(etcdBrowserSource).toContain(':disabled="!mirrorDeleteAvailable || transferLoading || transferApplying"');
});
it("snapshots the target and invalidates stale transfer previews", () => {
expect(etcdBrowserSource).toContain("const targetId = targetConnectionId.value");
expect(etcdBrowserSource).toContain("const generation = ++transferPreviewGeneration");
expect(etcdBrowserSource).toContain("if (generation !== transferPreviewGeneration) return");
expect(etcdBrowserSource).toContain(':disabled="transferLoading || transferApplying"');
});
it("refreshes a partial batch before allowing the remaining operations to retry", () => {
expect(etcdBrowserSource).toContain('row.operation = "applied"');
expect(etcdBrowserSource).toContain("await previewTransfer()");
expect(etcdBrowserSource).toContain('t("etcd.transferPartiallyApplied"');
});
});
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
describe("KvKeyBrowser edit dialog layout", () => {
it("keeps the value format label and selector together in the editor toolbar", () => {
expect(browserSource).toContain('class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"');
expect(browserSource).toContain('class="flex shrink-0 items-center gap-3"');
expect(browserSource).toContain('<SelectTrigger class="h-9 w-44">');
});
it("cancels the shared footer negative margin and preserves bottom safe spacing", () => {
expect(browserSource).toContain('<DialogFooter class="mx-0 mb-0 shrink-0 gap-3 border-t bg-muted/10 px-6 py-5">');
expect(browserSource).toContain('variant="outline" class="h-10 min-w-20"');
expect(browserSource).toContain('<Button class="h-10 min-w-20" :disabled="saving || readOnly"');
});
it("applies the same safe spacing to the history dialog footer", () => {
expect(browserSource).toContain('<DialogFooter class="mx-0 mb-0 shrink-0 border-t px-6 py-5">');
expect(browserSource).toContain('class="h-10 min-w-20" @click="showHistoryDialog = false"');
});
});
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
describe("KvKeyBrowser mutation errors", () => {
it("shows duplicate-key conflicts next to the Key field", () => {
expect(browserSource).toContain("classifyKvMutationError(error, isCreating.value");
expect(browserSource).toContain(`:aria-invalid="editErrorKind === 'keyAlreadyExists'"`);
expect(browserSource).toContain(`v-if="editError && editErrorKind === 'keyAlreadyExists'"`);
});
it("clears the duplicate warning after the Key name changes", () => {
expect(browserSource).toContain("watch(editKey, () =>");
expect(browserSource).toContain(`if (editErrorKind.value !== "keyAlreadyExists") return`);
});
});
@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
describe("KvKeyBrowser search and split layout", () => {
it("uses the requested prefix for a server-side, paginated search", () => {
expect(browserSource).toContain("props.api.listPrefix(connectionId, searchQuery, pageSize, reset ? null : continuation.value");
expect(browserSource).not.toContain("async function searchAllKeys");
expect(browserSource).not.toContain("filterKvKeysBySearch(result.keys, query)");
});
it("uses a persisted draggable split between the Key tree and details", () => {
expect(browserSource).toContain('<Splitpanes class="kv-browser-splitpanes min-h-0 flex-1" @resized="handleKvBrowserSplitResized">');
expect(browserSource).toContain('<Pane :size="kvBrowserSplitSize" min-size="20" max-size="70">');
expect(browserSource).toContain('<Pane :size="100 - kvBrowserSplitSize" min-size="30">');
expect(browserSource).toContain("safeLocalStorageSet(kvBrowserSplitSizeStorageKey, String(size))");
expect(browserSource).toContain("cursor: col-resize");
});
});
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
describe("KvKeyBrowser TTL details", () => {
it("renders the selected key TTL and refreshes it every second", () => {
expect(browserSource).toContain("const selectedTtlLabel = computed");
expect(browserSource).toContain('<Badge v-if="selectedTtlLabel" variant="outline">TTL {{ selectedTtlLabel }}</Badge>');
expect(browserSource).toContain("metadataRefreshTimer = setInterval");
expect(browserSource).toContain("void refreshSelectedMetadata(key, generation)");
});
it("stops TTL polling when the browser unmounts", () => {
expect(browserSource).toContain("onBeforeUnmount(() =>");
expect(browserSource).toContain("stopKeyListRefresh()");
expect(browserSource).toContain("stopMetadataRefresh()");
});
});
@@ -475,6 +475,7 @@ function tabMenuIcon(tab: QueryTab) {
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis") return Table2;
if (tab.mode === "vector") return TableProperties;
if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound;
if (tab.mode === "etcd-dashboard") return Gauge;
if (tab.mode === "nacos") return Network;
if (tab.mode === "objects") return TableProperties;
if (tab.mode === "structure") return PencilRuler;
@@ -618,6 +619,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
@@ -811,6 +813,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'etcd-dashboard'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
@@ -44,6 +44,7 @@ const DataGrid = defineAsyncComponent(loadDataGridComponent);
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
const EtcdDashboard = defineAsyncComponent(() => import("@/components/etcd/EtcdDashboard.vue"));
const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zookeeper/ZooKeeperKeyBrowser.vue"));
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
@@ -215,6 +216,7 @@ const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
const documentBrowserRef = ref<SearchableBrowserHandle>();
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
const etcdDashboardRef = ref<{ refresh?: () => boolean }>();
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const activeTableMeta = computed(() => props.activeTab.tableMeta);
@@ -711,6 +713,7 @@ function refreshData(): boolean {
// Reuse ObjectBrowser's reload path so schema reloads and stale object-response guards stay intact.
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "etcd-dashboard") return etcdDashboardRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
if (canReloadUnavailableDataTab(props.activeTab)) {
@@ -1574,6 +1577,13 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</template>
<!-- etcd Dashboard: cluster observation -->
<template v-else-if="activeTab.mode === 'etcd-dashboard'">
<div class="flex-1 min-h-0">
<EtcdDashboard ref="etcdDashboardRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
</div>
</template>
<!-- ZooKeeper mode: znode browser -->
<template v-else-if="activeTab.mode === 'zookeeper'">
<div class="flex-1 min-h-0">
@@ -978,7 +978,7 @@ function resolveLoadedLocateTarget(target: ActiveTabSidebarTarget, candidate: Qu
}
async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean }) {
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "zookeeper-root") return;
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "etcd-dashboard" || target.type === "zookeeper-root") return;
const connId = target.connectionId;
if (!connId) return;
@@ -581,6 +581,10 @@ async function toggle() {
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd");
refreshActiveKvBrowserAfterOpen("etcd", node.connectionId);
} else if (node.type === "etcd-dashboard" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:dashboard`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd-dashboard");
} else if (node.type === "zookeeper-root" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "ZooKeeper"}:keys`;
@@ -3784,7 +3788,7 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
const { node, items } = context;
// 5. Redis DB / Mongo DB
if (node.type === "etcd-root" || node.type === "zookeeper-root") {
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "zookeeper-root") {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
return true;
}
@@ -28,6 +28,7 @@ import {
Check,
UsersRound,
CalendarClock,
Gauge,
Lock,
Archive,
Square,
@@ -245,6 +246,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: FolderOpen, colorClass: "text-sky-500" };
case "etcd-root":
return { icon: Database, colorClass: "text-sky-500" };
case "etcd-dashboard":
return { icon: Gauge, colorClass: "text-sky-500" };
case "zookeeper-root":
return { icon: Database, colorClass: "text-blue-500" };
case "mongo-db":