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":
+144 -3
View File
@@ -836,6 +836,7 @@ export default {
tableData: "Table Data",
redis: "Redis",
etcd: "etcd",
etcdDashboard: "etcd Dashboard",
zookeeper: "ZooKeeper",
mongo: "Mongo",
gridfs: "GridFS",
@@ -2663,7 +2664,7 @@ export default {
resetLayout: "Reset layout",
},
etcd: {
prefixPlaceholder: "Prefix, e.g. /app/",
prefixPlaceholder: "Search Key path, e.g. /app/ or service",
newKey: "New Key",
loadingKeys: "Loading keys...",
empty: "No keys found",
@@ -2678,12 +2679,152 @@ export default {
keyPlaceholder: "/path/to/key",
keyRequired: "Key is required",
ttl: "TTL (seconds)",
ttlPlaceholder: "Optional; leave blank for no expiry",
ttlPlaceholder: "Enter a positive number of seconds",
ttlInvalid: "TTL must be a positive integer",
ttlUnavailable: "Update the etcd Agent and reconnect to configure TTL.",
expiryMode: "Expiry policy",
expiryPermanent: "Permanent",
expiryPermanentHint: "Do not attach a lease; the key will not expire automatically",
expiryTtl: "Time to live (TTL)",
expiryTtlHint: "Create a temporary lease that expires after the configured duration",
expiryLease: "Bind lease",
expiryLeaseHint: "Attach the key to an existing Lease ID",
leaseId: "Lease ID",
leasePlaceholder: "Enter an existing Lease ID",
leaseInvalid: "Lease ID must be a valid positive 64-bit integer",
valueContent: "Value content",
keyAlreadyExists: "A Key with this name already exists. Choose another name, or close this dialog and edit the existing Key.",
conflict: "This Key changed after it was loaded. Refresh it and try again.",
saved: "Key saved",
deleted: "Key deleted",
base64Readonly: "Base64 values are read-only in this version.",
base64Readonly: "Edit Base64 values in Base64 mode.",
rename: "Rename",
clone: "Clone",
copyKey: "Copy key",
export: "Export",
import: "Import",
sync: "Sync",
history: "History",
restore: "Restore this version",
compare: "Compare",
format: "Value format",
savePreview: "Review value changes",
globalSearch: "Global search",
searchPrefix: "Limit to prefix (optional)",
searchAll: "Key and value",
searchPlaceholder: "Search key or value contents",
searchProgress: "Scanned {scanned} keys, matched {matched}",
exportResults: "Export results",
exported: "Exported {count} keys",
importPreview: "Import preview",
syncPreview: "Sync preview",
targetConnection: "Select target etcd connection",
targetReadOnly: "The target connection is read-only; import and sync operations cannot be applied.",
mirrorDelete: "Delete extra target keys (mirror)",
mirrorDeleteWarning: "Mirror mode deletes target keys that are absent from the source. Review every delete operation before applying.",
preparingPreview: "Preparing a fixed-revision change preview...",
operation: "Operation",
reason: "Reason",
selectedOperations: "{count} operations selected",
applyOperations: "Apply operations",
transferApplied: "Applied {count} operations",
operationApplied: "Applied successfully",
transferPartiallyApplied: "Applied {count} operations before the batch stopped: {error}. The preview has been refreshed; retry only the remaining selected operations.",
transferPartialRefreshFailed: "Applied {count} operations before the batch stopped: {error}. Refreshing the preview also failed: {previewError}. Close and reopen the preview before retrying.",
targetChangedDuringTransfer: "The target connection changed while the batch was running",
dashboard: {
title: "etcd Dashboard",
description: "Observation based on client-reachable endpoints; this is not an authoritative quorum health check.",
autoRefreshOff: "Auto refresh off",
refresh: "Refresh",
retry: "Check again",
loadFailed: "Unable to load etcd Dashboard",
agentUpgradeTitle: "Dashboard is unavailable with the current etcd Agent",
agentUpgradeDescription: "This connection is still using an older Agent that cannot read cluster status. Key browsing and editing remain available.",
agentUpgradeSteps: "Update the etcd Agent in Driver Manager, then disconnect and reconnect this etcd connection. Select “Check again” when finished.",
observedHealth: "Observed health",
reachableMembers: "Reachable members",
keyCount: "Key count",
backendSize: "Backend size",
fragmentation: "Fragmentation estimate",
cluster: "cluster",
revision: "revision",
leader: "leader",
noAlarms: "No active alarms observed",
prometheusMetrics: "Prometheus runtime metrics",
prometheusDescription: "Stable metrics collected from etcd /metrics. Rates are calculated from two adjacent samples.",
collectedAt: "Collected at",
requests: "gRPC request rate",
requestFailureRate: "Request failure rate",
proposals: "Raft proposals",
proposalLag: "Committed-to-applied gap",
pending: "pending",
diskLatency: "Disk persistence latency",
walFsync: "WAL fsync avg",
backendCommit: "Backend commit avg",
networkTraffic: "Client network traffic",
peerTraffic: "Peer traffic",
processResources: "Process resources",
runtime: "Go runtime",
goroutines: "goroutines",
leaderChanges: "Leader changes total",
failedProposals: "Failed proposals total",
failedProposalRate: "Failure rate",
rateNeedsRefresh: "Refresh again to calculate live rates",
storageQuota: "Backend quota usage",
uptime: "Process uptime",
consensusReliability: "Consensus and reliability",
leadership: "Leadership",
knownPeers: "known peers",
proposalState: "Proposal state",
raftFailures: "Raft failures",
revisionState: "MVCC revision",
compactRevision: "Compact revision",
requestLoad: "Request load",
grpcMethod: "gRPC method",
total: "Total",
rate: "Rate",
failures: "Failures",
avgLatency: "Average latency",
storageDisk: "Storage and disk",
databaseState: "Database state",
inUse: "in use",
openReadTransactions: "Open read transactions",
backgroundTasks: "Background tasks",
watchState: "Watch state",
watchers: "Watchers",
watchStreams: "Watch streams",
events: "Event rate",
slowPending: "Slow / pending",
leaseState: "Lease lifecycle",
granted: "Granted rate",
renewed: "Renewed rate",
revokedExpired: "Revoked / expired",
averageTtl: "Average TTL",
runtimeResources: "Runtime resources",
memory: "Memory",
processNetworkGc: "Process network and GC",
metricsUnavailable: "Prometheus metrics are unavailable",
metricsUnavailableHint: "Cluster status remains available. DBX requests /metrics directly from the current etcd endpoint; verify that the resulting URL is reachable from the DBX application.",
endpointMember: "Endpoint / member",
role: "Role",
version: "Version",
raftTermApplied: "Raft term / applied",
dbSizeInUse: "DB size / in use",
latency: "Latency",
status: "Status",
learner: "learner",
follower: "follower",
reachable: "reachable",
unreachable: "unreachable",
loading: "Loading cluster observation…",
health: {
unknown: "unknown",
critical: "critical",
warning: "warning",
healthy: "healthy",
},
},
},
zookeeper: {
prefixPlaceholder: "Path prefix, e.g. /app/",
+143 -2
View File
@@ -859,6 +859,7 @@ export default withEnglishFallback({
fixedTabs: "Pestañas fijadas",
openDataTabs: "Tablas abiertas",
gridfs: "GridFS",
etcdDashboard: "Panel de etcd",
},
executionSummary: {
empty: "Sin resumen",
@@ -2515,7 +2516,7 @@ export default withEnglishFallback({
noJoinSql: "No hay SQL de relación para copiar",
},
etcd: {
prefixPlaceholder: "Prefijo, ej. /app/",
prefixPlaceholder: "Buscar ruta de Key, p. ej. /app/ o servicio",
newKey: "Nueva clave",
loadingKeys: "Cargando claves...",
empty: "No se encontraron claves",
@@ -2533,9 +2534,149 @@ export default withEnglishFallback({
ttlPlaceholder: "Opcional; vacío significa sin caducidad",
ttlInvalid: "El TTL debe ser un entero positivo",
ttlUnavailable: "Actualiza el Agent de etcd y vuelve a conectarte para configurar el TTL.",
expiryMode: "Política de caducidad",
expiryPermanent: "Permanente",
expiryPermanentHint: "No adjunta un lease; la clave no caducará automáticamente",
expiryTtl: "Tiempo de vida (TTL)",
expiryTtlHint: "Crea un lease temporal que caduca después del tiempo configurado",
expiryLease: "Vincular lease",
expiryLeaseHint: "Vincula la clave a un Lease ID existente",
leaseId: "Lease ID",
leasePlaceholder: "Introduce un Lease ID existente",
leaseInvalid: "El Lease ID debe ser un entero positivo válido de 64 bits",
valueContent: "Contenido del valor",
keyAlreadyExists: "Ya existe una Key con este nombre. Elige otro nombre o cierra este diálogo y edita la Key existente.",
conflict: "Esta Key cambió después de cargarse. Actualízala e inténtalo de nuevo.",
saved: "Clave guardada",
deleted: "Clave eliminada",
base64Readonly: "Los valores Base64 son de solo lectura en esta versión.",
base64Readonly: "Edita los valores Base64 en el modo Base64.",
rename: "Renombrar",
clone: "Clonar",
copyKey: "Copiar clave",
export: "Exportar",
import: "Importar",
sync: "Sincronizar",
history: "Historial",
restore: "Restaurar esta versión",
compare: "Comparar",
format: "Formato del valor",
savePreview: "Revisar cambios del valor",
globalSearch: "Búsqueda global",
searchPrefix: "Limitar a un prefijo (opcional)",
searchAll: "Clave y valor",
searchPlaceholder: "Buscar en el contenido de claves o valores",
searchProgress: "Se analizaron {scanned} claves, {matched} coincidencias",
exportResults: "Exportar resultados",
exported: "Se exportaron {count} claves",
importPreview: "Vista previa de importación",
syncPreview: "Vista previa de sincronización",
targetConnection: "Selecciona la conexión etcd de destino",
targetReadOnly: "La conexión de destino es de solo lectura; no se pueden aplicar importaciones ni sincronizaciones.",
mirrorDelete: "Eliminar claves adicionales del destino (espejo)",
mirrorDeleteWarning: "El modo espejo elimina las claves de destino que no existen en el origen. Revisa cada eliminación antes de aplicarla.",
preparingPreview: "Preparando una vista previa de cambios con revisión fija...",
operation: "Operación",
reason: "Motivo",
selectedOperations: "{count} operaciones seleccionadas",
applyOperations: "Aplicar operaciones",
transferApplied: "Se aplicaron {count} operaciones",
operationApplied: "Aplicada correctamente",
transferPartiallyApplied: "Se aplicaron {count} operaciones antes de detenerse el lote: {error}. La vista previa se actualizó; reintenta solo las operaciones restantes.",
transferPartialRefreshFailed: "Se aplicaron {count} operaciones antes de detenerse el lote: {error}. También falló la actualización de la vista previa: {previewError}. Cierra y vuelve a abrir la vista previa antes de reintentar.",
targetChangedDuringTransfer: "La conexión de destino cambió durante la ejecución del lote",
dashboard: {
title: "Panel de etcd",
description: "Observación basada en endpoints accesibles por el cliente; no es una comprobación autoritativa del quorum.",
autoRefreshOff: "Actualización automática desactivada",
refresh: "Actualizar",
retry: "Comprobar de nuevo",
loadFailed: "No se pudo cargar el panel de etcd",
agentUpgradeTitle: "El panel no está disponible con el Agent de etcd actual",
agentUpgradeDescription: "Esta conexión todavía usa un Agent antiguo que no puede leer el estado del clúster. La navegación y edición de claves siguen disponibles.",
agentUpgradeSteps: "Actualiza el Agent de etcd en el Gestor de drivers, desconecta y vuelve a conectar esta conexión. Después selecciona «Comprobar de nuevo».",
observedHealth: "Salud observada",
reachableMembers: "Miembros accesibles",
keyCount: "Número de claves",
backendSize: "Tamaño del backend",
fragmentation: "Fragmentación estimada",
cluster: "clúster",
revision: "revisión",
leader: "líder",
noAlarms: "No se observaron alarmas activas",
prometheusMetrics: "Métricas de ejecución de Prometheus",
prometheusDescription: "Métricas estables recopiladas desde /metrics de etcd. Las tasas se calculan entre dos muestras consecutivas.",
collectedAt: "Recopilado a las",
requests: "Tasa de solicitudes gRPC",
requestFailureRate: "Tasa de errores de solicitudes",
proposals: "Propuestas Raft",
proposalLag: "Diferencia entre confirmadas y aplicadas",
pending: "pendientes",
diskLatency: "Latencia de persistencia en disco",
walFsync: "Promedio de fsync de WAL",
backendCommit: "Promedio de commit del backend",
networkTraffic: "Tráfico de red del cliente",
peerTraffic: "Tráfico entre pares",
processResources: "Recursos del proceso",
runtime: "Runtime de Go",
goroutines: "goroutines",
leaderChanges: "Cambios de líder totales",
failedProposals: "Propuestas fallidas totales",
failedProposalRate: "Tasa de fallos",
rateNeedsRefresh: "Actualiza de nuevo para calcular las tasas en vivo",
storageQuota: "Uso de cuota del backend",
uptime: "Tiempo activo del proceso",
consensusReliability: "Consenso y fiabilidad",
leadership: "Liderazgo",
knownPeers: "pares conocidos",
proposalState: "Estado de propuestas",
raftFailures: "Fallos de Raft",
revisionState: "Revisión MVCC",
compactRevision: "Revisión compactada",
requestLoad: "Carga de solicitudes",
grpcMethod: "Método gRPC",
total: "Total",
rate: "Tasa",
failures: "Fallos",
avgLatency: "Latencia media",
storageDisk: "Almacenamiento y disco",
databaseState: "Estado de la base de datos",
inUse: "en uso",
openReadTransactions: "Transacciones de lectura abiertas",
backgroundTasks: "Tareas en segundo plano",
watchState: "Estado de watches",
watchers: "Watchers",
watchStreams: "Flujos de watch",
events: "Tasa de eventos",
slowPending: "Lentas / pendientes",
leaseState: "Ciclo de vida de leases",
granted: "Tasa de concesión",
renewed: "Tasa de renovación",
revokedExpired: "Revocados / caducados",
averageTtl: "TTL medio",
runtimeResources: "Recursos de ejecución",
memory: "Memoria",
processNetworkGc: "Red del proceso y GC",
metricsUnavailable: "Las métricas de Prometheus no están disponibles",
metricsUnavailableHint: "El estado del clúster sigue disponible. DBX solicita /metrics directamente al endpoint de etcd actual; comprueba que la URL resultante sea accesible desde DBX.",
endpointMember: "Endpoint / miembro",
role: "Rol",
version: "Versión",
raftTermApplied: "Término Raft / aplicado",
dbSizeInUse: "Tamaño de BD / en uso",
latency: "Latencia",
status: "Estado",
learner: "aprendiz",
follower: "seguidor",
reachable: "accesible",
unreachable: "inaccesible",
loading: "Cargando observación del clúster…",
health: {
unknown: "desconocido",
critical: "crítico",
warning: "advertencia",
healthy: "saludable",
},
},
},
zookeeper: {
prefixPlaceholder: "Prefijo de ruta, ej. /app/",
+144 -3
View File
@@ -857,6 +857,7 @@ export default withEnglishFallback({
fixedTabs: "Schede bloccate",
openDataTabs: "Tabelle aperte",
gridfs: "GridFS",
etcdDashboard: "etcd dashboard",
},
executionSummary: {
empty: "Nessun riepilogo",
@@ -2513,8 +2514,8 @@ export default withEnglishFallback({
resetLayout: "Reimposta layout",
},
etcd: {
prefixPlaceholder: "Prefisso, es. /app/",
newKey: "Nuova Chiave",
prefixPlaceholder: "Cerca percorso Key, ad es. /app/ o servizio",
newKey: "Nuova chiave",
loadingKeys: "Caricamento chiavi...",
empty: "Nessuna chiave trovata",
loadMore: "Carica altro",
@@ -2531,9 +2532,149 @@ export default withEnglishFallback({
ttlPlaceholder: "Facoltativo; vuoto significa nessuna scadenza",
ttlInvalid: "Il TTL deve essere un numero intero positivo",
ttlUnavailable: "Aggiorna l'Agent etcd e riconnettiti per configurare il TTL.",
expiryMode: "Criterio di scadenza",
expiryPermanent: "Permanente",
expiryPermanentHint: "Non associa un lease; la chiave non scadrà automaticamente",
expiryTtl: "Durata (TTL)",
expiryTtlHint: "Crea un lease temporaneo che scade dopo la durata configurata",
expiryLease: "Associa lease",
expiryLeaseHint: "Associa la chiave a un Lease ID esistente",
leaseId: "Lease ID",
leasePlaceholder: "Inserisci un Lease ID esistente",
leaseInvalid: "Il Lease ID deve essere un intero positivo valido a 64 bit",
valueContent: "Contenuto del valore",
keyAlreadyExists: "Esiste già una Key con questo nome. Scegli un altro nome oppure chiudi questa finestra e modifica la Key esistente.",
conflict: "Questa Key è cambiata dopo il caricamento. Aggiornala e riprova.",
saved: "Chiave salvata",
deleted: "Chiave eliminata",
base64Readonly: "I valori Base64 sono in sola lettura in questa versione.",
base64Readonly: "Modifica i valori Base64 in modalità Base64.",
rename: "Rinomina",
clone: "Clona",
copyKey: "Copia chiave",
export: "Esporta",
import: "Importa",
sync: "Sincronizza",
history: "Cronologia",
restore: "Ripristina questa versione",
compare: "Confronta",
format: "Formato del valore",
savePreview: "Rivedi le modifiche al valore",
globalSearch: "Ricerca globale",
searchPrefix: "Limita al prefisso (facoltativo)",
searchAll: "Chiave e valore",
searchPlaceholder: "Cerca nel contenuto di chiavi o valori",
searchProgress: "Analizzate {scanned} chiavi, {matched} corrispondenze",
exportResults: "Esporta risultati",
exported: "Esportate {count} chiavi",
importPreview: "Anteprima importazione",
syncPreview: "Anteprima sincronizzazione",
targetConnection: "Seleziona la connessione etcd di destinazione",
targetReadOnly: "La connessione di destinazione è di sola lettura; importazione e sincronizzazione non possono essere applicate.",
mirrorDelete: "Elimina le chiavi aggiuntive dalla destinazione (mirror)",
mirrorDeleteWarning: "La modalità mirror elimina le chiavi di destinazione assenti nell'origine. Controlla ogni eliminazione prima di applicarla.",
preparingPreview: "Preparazione dell'anteprima delle modifiche a revisione fissa...",
operation: "Operazione",
reason: "Motivo",
selectedOperations: "{count} operazioni selezionate",
applyOperations: "Applica operazioni",
transferApplied: "Applicate {count} operazioni",
operationApplied: "Applicata correttamente",
transferPartiallyApplied: "Sono state applicate {count} operazioni prima dell'arresto del batch: {error}. L'anteprima è stata aggiornata; riprova solo le operazioni rimanenti.",
transferPartialRefreshFailed: "Sono state applicate {count} operazioni prima dell'arresto del batch: {error}. Anche l'aggiornamento dell'anteprima non è riuscito: {previewError}. Chiudi e riapri l'anteprima prima di riprovare.",
targetChangedDuringTransfer: "La connessione di destinazione è cambiata durante l'esecuzione del batch",
dashboard: {
title: "Dashboard etcd",
description: "Osservazione basata sugli endpoint raggiungibili dal client; non è un controllo autorevole dello stato del quorum.",
autoRefreshOff: "Aggiornamento automatico disattivato",
refresh: "Aggiorna",
retry: "Controlla di nuovo",
loadFailed: "Impossibile caricare la Dashboard etcd",
agentUpgradeTitle: "La Dashboard non è disponibile con l'Agent etcd corrente",
agentUpgradeDescription: "Questa connessione usa ancora un Agent precedente che non può leggere lo stato del cluster. La navigazione e la modifica delle chiavi restano disponibili.",
agentUpgradeSteps: "Aggiorna l'Agent etcd nel Gestore driver, quindi disconnetti e riconnetti la connessione. Al termine seleziona «Controlla di nuovo».",
observedHealth: "Stato osservato",
reachableMembers: "Membri raggiungibili",
keyCount: "Numero di chiavi",
backendSize: "Dimensione backend",
fragmentation: "Frammentazione stimata",
cluster: "cluster",
revision: "revisione",
leader: "leader",
noAlarms: "Nessun allarme attivo osservato",
prometheusMetrics: "Metriche runtime Prometheus",
prometheusDescription: "Metriche stabili raccolte da /metrics di etcd. Le frequenze sono calcolate tra due campioni consecutivi.",
collectedAt: "Raccolte alle",
requests: "Frequenza richieste gRPC",
requestFailureRate: "Tasso di errore delle richieste",
proposals: "Proposte Raft",
proposalLag: "Differenza tra commit e applicazione",
pending: "in attesa",
diskLatency: "Latenza di persistenza su disco",
walFsync: "Media fsync WAL",
backendCommit: "Media commit backend",
networkTraffic: "Traffico di rete client",
peerTraffic: "Traffico peer",
processResources: "Risorse del processo",
runtime: "Runtime Go",
goroutines: "goroutine",
leaderChanges: "Cambi leader totali",
failedProposals: "Proposte non riuscite totali",
failedProposalRate: "Tasso di errore",
rateNeedsRefresh: "Aggiorna di nuovo per calcolare le frequenze in tempo reale",
storageQuota: "Utilizzo quota backend",
uptime: "Tempo di attività del processo",
consensusReliability: "Consenso e affidabilità",
leadership: "Leadership",
knownPeers: "peer noti",
proposalState: "Stato proposte",
raftFailures: "Errori Raft",
revisionState: "Revisione MVCC",
compactRevision: "Revisione compattata",
requestLoad: "Carico richieste",
grpcMethod: "Metodo gRPC",
total: "Totale",
rate: "Frequenza",
failures: "Errori",
avgLatency: "Latenza media",
storageDisk: "Archiviazione e disco",
databaseState: "Stato database",
inUse: "in uso",
openReadTransactions: "Transazioni di lettura aperte",
backgroundTasks: "Attività in background",
watchState: "Stato watch",
watchers: "Watcher",
watchStreams: "Flussi watch",
events: "Frequenza eventi",
slowPending: "Lente / in attesa",
leaseState: "Ciclo di vita lease",
granted: "Frequenza concessioni",
renewed: "Frequenza rinnovi",
revokedExpired: "Revocati / scaduti",
averageTtl: "TTL medio",
runtimeResources: "Risorse runtime",
memory: "Memoria",
processNetworkGc: "Rete del processo e GC",
metricsUnavailable: "Le metriche Prometheus non sono disponibili",
metricsUnavailableHint: "Lo stato del cluster resta disponibile. DBX richiede /metrics direttamente all'endpoint etcd corrente; verifica che l'URL risultante sia raggiungibile dall'applicazione DBX.",
endpointMember: "Endpoint / membro",
role: "Ruolo",
version: "Versione",
raftTermApplied: "Termine Raft / applicato",
dbSizeInUse: "Dimensione DB / in uso",
latency: "Latenza",
status: "Stato",
learner: "learner",
follower: "follower",
reachable: "raggiungibile",
unreachable: "non raggiungibile",
loading: "Caricamento osservazione del cluster…",
health: {
unknown: "sconosciuto",
critical: "critico",
warning: "avviso",
healthy: "sano",
},
},
},
zookeeper: {
prefixPlaceholder: "Prefisso percorso, es. /app/",
+143 -2
View File
@@ -858,6 +858,7 @@ export default withEnglishFallback({
zookeeper: "ZooKeeper",
vector: "ベクター",
gridfs: "GridFS",
etcdDashboard: "etcd ダッシュボード",
},
executionSummary: {
empty: "サマリーはありません",
@@ -2548,7 +2549,7 @@ export default withEnglishFallback({
resetLayout: "レイアウトをリセット",
},
etcd: {
prefixPlaceholder: "プレフィックス、例: /app/",
prefixPlaceholder: "Key パスを検索(例: /app/、service)",
newKey: "新しいキー",
loadingKeys: "キーを読み込み中...",
empty: "キーが見つかりません",
@@ -2566,9 +2567,149 @@ export default withEnglishFallback({
ttlPlaceholder: "任意。空欄の場合は有効期限なし",
ttlInvalid: "TTLには正の整数を指定してください",
ttlUnavailable: "TTLを設定するにはetcd Agentを更新して再接続してください。",
expiryMode: "有効期限ポリシー",
expiryPermanent: "無期限",
expiryPermanentHint: "Lease を関連付けず、キーを自動的に期限切れにしません",
expiryTtl: "有効期間(TTL)",
expiryTtlHint: "設定した期間後に期限切れとなる一時 Lease を作成します",
expiryLease: "Lease を関連付け",
expiryLeaseHint: "既存の Lease ID にキーを関連付けます",
leaseId: "Lease ID",
leasePlaceholder: "既存の Lease ID を入力",
leaseInvalid: "Lease ID には有効な正の 64 ビット整数を指定してください",
valueContent: "値の内容",
keyAlreadyExists: "同じ名前の Key がすでに存在します。別の名前を選ぶか、このダイアログを閉じて既存の Key を編集してください。",
conflict: "読み込み後にこの Key が変更されました。更新してから再試行してください。",
saved: "キーを保存しました",
deleted: "キーを削除しました",
base64Readonly: "Base64値はこのバージョンでは読み取り専用です。",
base64Readonly: "Base64 値は Base64 モードで編集してください。",
rename: "名前を変更",
clone: "複製",
copyKey: "キーをコピー",
export: "エクスポート",
import: "インポート",
sync: "同期",
history: "履歴",
restore: "このバージョンを復元",
compare: "比較",
format: "値の形式",
savePreview: "値の変更を確認",
globalSearch: "グローバル検索",
searchPrefix: "プレフィックスで絞り込み(任意)",
searchAll: "キーと値",
searchPlaceholder: "キーまたは値の内容を検索",
searchProgress: "{scanned} 件のキーを走査、{matched} 件一致",
exportResults: "結果をエクスポート",
exported: "{count} 件のキーをエクスポートしました",
importPreview: "インポートのプレビュー",
syncPreview: "同期のプレビュー",
targetConnection: "対象の etcd 接続を選択",
targetReadOnly: "対象の接続は読み取り専用のため、インポートや同期を適用できません。",
mirrorDelete: "対象の余分なキーを削除(ミラー)",
mirrorDeleteWarning: "ミラーモードでは、ソースに存在しない対象キーを削除します。適用前に各削除操作を確認してください。",
preparingPreview: "固定リビジョンの変更プレビューを準備中...",
operation: "操作",
reason: "理由",
selectedOperations: "{count} 件の操作を選択",
applyOperations: "操作を適用",
transferApplied: "{count} 件の操作を適用しました",
operationApplied: "適用済み",
transferPartiallyApplied: "バッチ停止前に {count} 件の操作を適用しました: {error}。プレビューを更新したため、残りの操作のみ再試行してください。",
transferPartialRefreshFailed: "バッチ停止前に {count} 件の操作を適用しました: {error}。プレビューの更新にも失敗しました: {previewError}。再試行する前にプレビューを閉じて開き直してください。",
targetChangedDuringTransfer: "バッチ実行中に対象の接続が変更されました",
dashboard: {
title: "etcd ダッシュボード",
description: "クライアントから到達可能なエンドポイントに基づく観測であり、正式な Quorum ヘルスチェックではありません。",
autoRefreshOff: "自動更新オフ",
refresh: "更新",
retry: "再確認",
loadFailed: "etcd ダッシュボードを読み込めません",
agentUpgradeTitle: "現在の etcd Agent ではダッシュボードを利用できません",
agentUpgradeDescription: "この接続では、クラスタ状態を読み取れない古い Agent が使用されています。キーの参照と編集は引き続き利用できます。",
agentUpgradeSteps: "ドライバーマネージャーで etcd Agent を更新し、この etcd 接続を切断して再接続してください。完了後に「再確認」を選択します。",
observedHealth: "観測された状態",
reachableMembers: "到達可能なメンバー",
keyCount: "キー数",
backendSize: "バックエンドサイズ",
fragmentation: "断片化の推定値",
cluster: "クラスタ",
revision: "リビジョン",
leader: "リーダー",
noAlarms: "有効なアラームは観測されていません",
prometheusMetrics: "Prometheus ランタイムメトリクス",
prometheusDescription: "etcd の /metrics から安定した指標を収集します。レートは連続する 2 回のサンプルから計算されます。",
collectedAt: "収集時刻",
requests: "gRPC リクエストレート",
requestFailureRate: "リクエスト失敗率",
proposals: "Raft プロポーザル",
proposalLag: "コミット済みと適用済みの差",
pending: "保留中",
diskLatency: "ディスク永続化レイテンシ",
walFsync: "WAL fsync 平均",
backendCommit: "バックエンド commit 平均",
networkTraffic: "クライアントネットワークトラフィック",
peerTraffic: "Peer トラフィック",
processResources: "プロセスリソース",
runtime: "Go ランタイム",
goroutines: "goroutine",
leaderChanges: "リーダー変更の累計",
failedProposals: "失敗したプロポーザルの累計",
failedProposalRate: "失敗率",
rateNeedsRefresh: "リアルタイムレートを計算するにはもう一度更新してください",
storageQuota: "バックエンドクォータ使用量",
uptime: "プロセス稼働時間",
consensusReliability: "コンセンサスと信頼性",
leadership: "リーダーシップ",
knownPeers: "既知の Peer",
proposalState: "プロポーザル状態",
raftFailures: "Raft 障害",
revisionState: "MVCC リビジョン",
compactRevision: "圧縮済みリビジョン",
requestLoad: "リクエスト負荷",
grpcMethod: "gRPC メソッド",
total: "合計",
rate: "レート",
failures: "失敗",
avgLatency: "平均レイテンシ",
storageDisk: "ストレージとディスク",
databaseState: "データベース状態",
inUse: "使用中",
openReadTransactions: "開いている読み取りトランザクション",
backgroundTasks: "バックグラウンドタスク",
watchState: "Watch 状態",
watchers: "Watcher",
watchStreams: "Watch ストリーム",
events: "イベントレート",
slowPending: "低速 / 保留中",
leaseState: "Lease ライフサイクル",
granted: "付与レート",
renewed: "更新レート",
revokedExpired: "取り消し / 期限切れ",
averageTtl: "平均 TTL",
runtimeResources: "ランタイムリソース",
memory: "メモリ",
processNetworkGc: "プロセスネットワークと GC",
metricsUnavailable: "Prometheus メトリクスを利用できません",
metricsUnavailableHint: "クラスタ状態は引き続き利用できます。DBX は現在の etcd エンドポイントへ直接 /metrics を要求するため、生成された URL に DBX アプリケーションから到達できることを確認してください。",
endpointMember: "エンドポイント / メンバー",
role: "ロール",
version: "バージョン",
raftTermApplied: "Raft term / 適用済み",
dbSizeInUse: "DB サイズ / 使用中",
latency: "レイテンシ",
status: "状態",
learner: "Learner",
follower: "Follower",
reachable: "到達可能",
unreachable: "到達不能",
loading: "クラスタ観測を読み込み中…",
health: {
unknown: "不明",
critical: "重大",
warning: "警告",
healthy: "正常",
},
},
},
redis: {
createKeyTypeHelp: {
+144 -3
View File
@@ -859,6 +859,7 @@ export default withEnglishFallback({
fixedTabs: "Abas fixadas",
openDataTabs: "Tabelas abertas",
gridfs: "GridFS",
etcdDashboard: "painel etcd",
},
executionSummary: {
empty: "Nenhum resumo",
@@ -2515,8 +2516,8 @@ export default withEnglishFallback({
resetLayout: "Redefinir layout",
},
etcd: {
prefixPlaceholder: "Prefixo, ex.: /app/",
newKey: "Nova Chave",
prefixPlaceholder: "Buscar caminho da Key, por exemplo /app/ ou serviço",
newKey: "Nova chave",
loadingKeys: "Carregando chaves...",
empty: "Nenhuma chave encontrada",
loadMore: "Carregar mais",
@@ -2533,9 +2534,149 @@ export default withEnglishFallback({
ttlPlaceholder: "Opcional; vazio significa sem expiração",
ttlInvalid: "O TTL deve ser um número inteiro positivo",
ttlUnavailable: "Atualize o Agent do etcd e reconecte para configurar o TTL.",
expiryMode: "Política de expiração",
expiryPermanent: "Permanente",
expiryPermanentHint: "Não associa um lease; a chave não expirará automaticamente",
expiryTtl: "Tempo de vida (TTL)",
expiryTtlHint: "Cria um lease temporário que expira após o período configurado",
expiryLease: "Vincular lease",
expiryLeaseHint: "Vincula a chave a um Lease ID existente",
leaseId: "Lease ID",
leasePlaceholder: "Informe um Lease ID existente",
leaseInvalid: "O Lease ID deve ser um inteiro positivo válido de 64 bits",
valueContent: "Conteúdo do valor",
keyAlreadyExists: "Já existe uma Key com esse nome. Escolha outro nome ou feche esta janela e edite a Key existente.",
conflict: "Esta Key foi alterada depois de ser carregada. Atualize e tente novamente.",
saved: "Chave salva",
deleted: "Chave excluída",
base64Readonly: "Valores Base64 são somente leitura nesta versão.",
base64Readonly: "Edite valores Base64 no modo Base64.",
rename: "Renomear",
clone: "Clonar",
copyKey: "Copiar chave",
export: "Exportar",
import: "Importar",
sync: "Sincronizar",
history: "Histórico",
restore: "Restaurar esta versão",
compare: "Comparar",
format: "Formato do valor",
savePreview: "Revisar alterações do valor",
globalSearch: "Busca global",
searchPrefix: "Limitar ao prefixo (opcional)",
searchAll: "Chave e valor",
searchPlaceholder: "Buscar no conteúdo da chave ou do valor",
searchProgress: "{scanned} chaves analisadas, {matched} correspondências",
exportResults: "Exportar resultados",
exported: "{count} chaves exportadas",
importPreview: "Prévia da importação",
syncPreview: "Prévia da sincronização",
targetConnection: "Selecione a conexão etcd de destino",
targetReadOnly: "A conexão de destino é somente leitura; não é possível aplicar importações nem sincronizações.",
mirrorDelete: "Excluir chaves extras do destino (espelho)",
mirrorDeleteWarning: "O modo espelho exclui chaves de destino ausentes na origem. Revise cada exclusão antes de aplicar.",
preparingPreview: "Preparando prévia de alterações em uma revisão fixa...",
operation: "Operação",
reason: "Motivo",
selectedOperations: "{count} operações selecionadas",
applyOperations: "Aplicar operações",
transferApplied: "{count} operações aplicadas",
operationApplied: "Aplicada com sucesso",
transferPartiallyApplied: "{count} operações foram aplicadas antes de o lote parar: {error}. A prévia foi atualizada; tente novamente apenas as operações restantes.",
transferPartialRefreshFailed: "{count} operações foram aplicadas antes de o lote parar: {error}. A atualização da prévia também falhou: {previewError}. Feche e reabra a prévia antes de tentar novamente.",
targetChangedDuringTransfer: "A conexão de destino mudou durante a execução do lote",
dashboard: {
title: "Painel do etcd",
description: "Observação baseada nos endpoints acessíveis pelo cliente; não é uma verificação autoritativa da integridade do quorum.",
autoRefreshOff: "Atualização automática desativada",
refresh: "Atualizar",
retry: "Verificar novamente",
loadFailed: "Não foi possível carregar o Painel do etcd",
agentUpgradeTitle: "O Painel não está disponível com o Agent do etcd atual",
agentUpgradeDescription: "Esta conexão ainda usa um Agent antigo que não consegue ler o estado do cluster. A navegação e edição de chaves continuam disponíveis.",
agentUpgradeSteps: "Atualize o Agent do etcd no Gerenciador de drivers, desconecte e reconecte esta conexão. Depois selecione “Verificar novamente”.",
observedHealth: "Integridade observada",
reachableMembers: "Membros acessíveis",
keyCount: "Quantidade de chaves",
backendSize: "Tamanho do backend",
fragmentation: "Fragmentação estimada",
cluster: "cluster",
revision: "revisão",
leader: "líder",
noAlarms: "Nenhum alarme ativo observado",
prometheusMetrics: "Métricas de execução do Prometheus",
prometheusDescription: "Métricas estáveis coletadas do /metrics do etcd. As taxas são calculadas entre duas amostras consecutivas.",
collectedAt: "Coletado às",
requests: "Taxa de solicitações gRPC",
requestFailureRate: "Taxa de falha das solicitações",
proposals: "Propostas Raft",
proposalLag: "Diferença entre confirmadas e aplicadas",
pending: "pendentes",
diskLatency: "Latência de persistência em disco",
walFsync: "Média de fsync do WAL",
backendCommit: "Média de commit do backend",
networkTraffic: "Tráfego de rede do cliente",
peerTraffic: "Tráfego entre pares",
processResources: "Recursos do processo",
runtime: "Runtime do Go",
goroutines: "goroutines",
leaderChanges: "Total de mudanças de líder",
failedProposals: "Total de propostas com falha",
failedProposalRate: "Taxa de falha",
rateNeedsRefresh: "Atualize novamente para calcular as taxas em tempo real",
storageQuota: "Uso da cota do backend",
uptime: "Tempo de atividade do processo",
consensusReliability: "Consenso e confiabilidade",
leadership: "Liderança",
knownPeers: "pares conhecidos",
proposalState: "Estado das propostas",
raftFailures: "Falhas do Raft",
revisionState: "Revisão MVCC",
compactRevision: "Revisão compactada",
requestLoad: "Carga de solicitações",
grpcMethod: "Método gRPC",
total: "Total",
rate: "Taxa",
failures: "Falhas",
avgLatency: "Latência média",
storageDisk: "Armazenamento e disco",
databaseState: "Estado do banco de dados",
inUse: "em uso",
openReadTransactions: "Transações de leitura abertas",
backgroundTasks: "Tarefas em segundo plano",
watchState: "Estado dos watches",
watchers: "Watchers",
watchStreams: "Fluxos de watch",
events: "Taxa de eventos",
slowPending: "Lentas / pendentes",
leaseState: "Ciclo de vida dos leases",
granted: "Taxa de concessão",
renewed: "Taxa de renovação",
revokedExpired: "Revogados / expirados",
averageTtl: "TTL médio",
runtimeResources: "Recursos de execução",
memory: "Memória",
processNetworkGc: "Rede do processo e GC",
metricsUnavailable: "As métricas do Prometheus não estão disponíveis",
metricsUnavailableHint: "O estado do cluster continua disponível. O DBX solicita /metrics diretamente ao endpoint etcd atual; confirme se a URL resultante pode ser acessada pelo aplicativo DBX.",
endpointMember: "Endpoint / membro",
role: "Função",
version: "Versão",
raftTermApplied: "Termo Raft / aplicado",
dbSizeInUse: "Tamanho do BD / em uso",
latency: "Latência",
status: "Status",
learner: "aprendiz",
follower: "seguidor",
reachable: "acessível",
unreachable: "inacessível",
loading: "Carregando observação do cluster…",
health: {
unknown: "desconhecido",
critical: "crítico",
warning: "aviso",
healthy: "saudável",
},
},
},
zookeeper: {
prefixPlaceholder: "Prefixo de caminho, ex.: /app/",
+144 -3
View File
@@ -837,6 +837,7 @@ export default withEnglishFallback({
tableData: "数据表",
redis: "Redis",
etcd: "etcd",
etcdDashboard: "etcd 大盘",
zookeeper: "ZooKeeper",
mongo: "Mongo",
gridfs: "GridFS",
@@ -2663,7 +2664,7 @@ export default withEnglishFallback({
resetLayout: "重置布局",
},
etcd: {
prefixPlaceholder: "Prefix,例如 /app/",
prefixPlaceholder: "搜索 Key 路径,例如 /app/ 或 service",
newKey: "新建 Key",
loadingKeys: "正在加载 Key...",
empty: "未找到 Key",
@@ -2678,12 +2679,152 @@ export default withEnglishFallback({
keyPlaceholder: "/path/to/key",
keyRequired: "Key 不能为空",
ttl: "TTL(秒)",
ttlPlaceholder: "可选,留空表示永不过期",
ttlPlaceholder: "请输入正整数秒数",
ttlInvalid: "TTL 必须是正整数",
ttlUnavailable: "请更新 etcd Agent 并重新连接后再配置 TTL。",
expiryMode: "有效期策略",
expiryPermanent: "长期有效",
expiryPermanentHint: "不绑定租约,Key 不会自动过期",
expiryTtl: "过期时间(TTL)",
expiryTtlHint: "创建临时租约,并在指定秒数后过期",
expiryLease: "绑定租约",
expiryLeaseHint: "将 Key 绑定到一个已有的 Lease ID",
leaseId: "Lease ID",
leasePlaceholder: "请输入已有的 Lease ID",
leaseInvalid: "Lease ID 必须是有效的正 64 位整数",
valueContent: "Value 内容",
keyAlreadyExists: "同名 Key 已存在,请更换 Key 名称,或关闭弹窗后编辑现有 Key。",
conflict: "该 Key 在加载后已发生变化,请刷新后重试。",
saved: "Key 已保存",
deleted: "Key 已删除",
base64Readonly: "Base64 值当前版本只读。",
base64Readonly: "Base64 值需要使用 Base64 模式编辑。",
rename: "重命名",
clone: "克隆",
copyKey: "复制 Key",
export: "导出",
import: "导入",
sync: "同步",
history: "历史版本",
restore: "恢复此版本",
compare: "对比",
format: "值格式",
savePreview: "确认值变更",
globalSearch: "全局搜索",
searchPrefix: "限定 Prefix(可选)",
searchAll: "Key 与 Value",
searchPlaceholder: "搜索 Key 或 Value 内容",
searchProgress: "已扫描 {scanned} 个 Key,命中 {matched} 个",
exportResults: "导出结果",
exported: "已导出 {count} 个 Key",
importPreview: "导入预览",
syncPreview: "同步预览",
targetConnection: "选择目标 etcd 连接",
targetReadOnly: "目标连接为只读,不能应用导入或同步操作。",
mirrorDelete: "镜像删除目标多余 Key",
mirrorDeleteWarning: "镜像模式会删除目标端不存在于源端的 Key;应用前请再次核对下方删除项。",
preparingPreview: "正在生成固定 Revision 的变更预览...",
operation: "操作",
reason: "说明",
selectedOperations: "已选择 {count} 项操作",
applyOperations: "应用操作",
transferApplied: "已成功应用 {count} 项操作",
operationApplied: "已成功应用",
transferPartiallyApplied: "批量任务停止前已成功应用 {count} 项:{error}。预览已刷新,请仅重试剩余选中项。",
transferPartialRefreshFailed: "批量任务停止前已成功应用 {count} 项:{error}。重新生成预览也失败:{previewError}。请关闭并重新打开预览后再重试。",
targetChangedDuringTransfer: "批量任务执行期间目标连接发生了变化",
dashboard: {
title: "etcd 概览",
description: "基于当前客户端可访问端点的观测结果,不等同于权威的集群 Quorum 健康检查。",
autoRefreshOff: "关闭自动刷新",
refresh: "刷新",
retry: "重新检测",
loadFailed: "无法加载 etcd 概览",
agentUpgradeTitle: "当前 etcd Agent 暂不支持 Dashboard",
agentUpgradeDescription: "当前连接仍在使用旧版 Agent,因此无法读取集群状态。Key 的查看和编辑不受影响。",
agentUpgradeSteps: "请在驱动管理中更新 etcd Agent,然后断开并重新连接该 etcd 连接。完成后点击“重新检测”。",
observedHealth: "观测健康度",
reachableMembers: "可访问成员",
keyCount: "Key 数量",
backendSize: "后端存储大小",
fragmentation: "碎片率估算",
cluster: "集群",
revision: "Revision",
leader: "Leader",
noAlarms: "未观测到活动告警",
prometheusMetrics: "Prometheus 运行指标",
prometheusDescription: "采集 etcd /metrics 的稳定指标;速率类数据由相邻两次采样计算。",
collectedAt: "采集于",
requests: "gRPC 请求速率",
requestFailureRate: "请求失败率",
proposals: "Raft Proposals",
proposalLag: "已提交与已应用差值",
pending: "等待中",
diskLatency: "磁盘持久化延迟",
walFsync: "WAL fsync 平均",
backendCommit: "Backend commit 平均",
networkTraffic: "客户端网络流量",
peerTraffic: "Peer 流量",
processResources: "进程资源",
runtime: "Go 运行时",
goroutines: "goroutines",
leaderChanges: "Leader 变更累计",
failedProposals: "失败 Proposal 累计",
failedProposalRate: "失败速率",
rateNeedsRefresh: "再次刷新后可显示实时速率",
storageQuota: "后端配额使用率",
uptime: "进程运行时长",
consensusReliability: "共识与可靠性",
leadership: "Leader 状态",
knownPeers: "已知 Peer",
proposalState: "Proposal 状态",
raftFailures: "Raft 异常累计",
revisionState: "MVCC Revision",
compactRevision: "压缩 Revision",
requestLoad: "请求负载",
grpcMethod: "gRPC 方法",
total: "累计",
rate: "速率",
failures: "失败",
avgLatency: "平均延迟",
storageDisk: "存储与磁盘",
databaseState: "数据库状态",
inUse: "已使用",
openReadTransactions: "只读事务",
backgroundTasks: "后台任务",
watchState: "Watch 状态",
watchers: "Watcher",
watchStreams: "Watch 流",
events: "事件速率",
slowPending: "慢 Watch / 待处理",
leaseState: "租约生命周期",
granted: "创建速率",
renewed: "续租速率",
revokedExpired: "撤销 / 过期",
averageTtl: "平均 TTL",
runtimeResources: "运行时资源",
memory: "内存",
processNetworkGc: "进程网络与 GC",
metricsUnavailable: "Prometheus 指标暂不可用",
metricsUnavailableHint: "集群状态仍可正常查看。DBX 会直接访问当前 etcd endpoint 拼接后的 /metrics,请确认该地址可由 DBX 应用访问。",
endpointMember: "端点 / 成员",
role: "角色",
version: "版本",
raftTermApplied: "Raft 任期 / 已应用",
dbSizeInUse: "存储大小 / 已使用",
latency: "延迟",
status: "状态",
learner: "Learner",
follower: "Follower",
reachable: "可访问",
unreachable: "不可访问",
loading: "正在观测集群状态…",
health: {
unknown: "未知",
critical: "异常",
warning: "警告",
healthy: "健康",
},
},
},
zookeeper: {
prefixPlaceholder: "路径前缀,例如 /app/",
+143 -2
View File
@@ -858,6 +858,7 @@ export default withEnglishFallback({
fixedTabs: "固定分頁",
openDataTabs: "已開啟的資料表",
gridfs: "GridFS",
etcdDashboard: "etcd 儀表板",
},
executionSummary: {
empty: "無摘要",
@@ -4524,7 +4525,7 @@ export default withEnglishFallback({
applyFailed: "執行失敗:{message}",
},
etcd: {
prefixPlaceholder: "前綴,例如 /app/",
prefixPlaceholder: "搜尋 Key 路徑,例如 /app/ 或 service",
newKey: "新增 Key",
loadingKeys: "正在載入 key...",
empty: "未找到 key",
@@ -4542,9 +4543,149 @@ export default withEnglishFallback({
ttlPlaceholder: "選填,留空表示永不過期",
ttlInvalid: "TTL 必須是正整數",
ttlUnavailable: "請更新 etcd Agent 並重新連線後再設定 TTL。",
expiryMode: "過期策略",
expiryPermanent: "永久有效",
expiryPermanentHint: "不綁定 Lease,Key 不會自動過期",
expiryTtl: "存活時間(TTL)",
expiryTtlHint: "建立一個在設定時間後過期的臨時 Lease",
expiryLease: "綁定 Lease",
expiryLeaseHint: "將 Key 綁定到現有的 Lease ID",
leaseId: "Lease ID",
leasePlaceholder: "請輸入現有的 Lease ID",
leaseInvalid: "Lease ID 必須是有效的正 64 位元整數",
valueContent: "值內容",
keyAlreadyExists: "已存在同名 Key。請使用其他名稱,或關閉此視窗後編輯現有 Key。",
conflict: "此 Key 在載入後已被修改,請重新整理後再試。",
saved: "Key 已儲存",
deleted: "Key 已刪除",
base64Readonly: "Base64 值在此版本中為唯讀。",
base64Readonly: "請在 Base64 模式下編輯 Base64 值。",
rename: "重新命名",
clone: "複製",
copyKey: "複製 Key",
export: "匯出",
import: "匯入",
sync: "同步",
history: "歷史版本",
restore: "恢復此版本",
compare: "比較",
format: "值格式",
savePreview: "檢查值的變更",
globalSearch: "全域搜尋",
searchPrefix: "限定前綴(選填)",
searchAll: "Key 與值",
searchPlaceholder: "搜尋 Key 或值內容",
searchProgress: "已掃描 {scanned} 個 Key,符合 {matched} 個",
exportResults: "匯出結果",
exported: "已匯出 {count} 個 Key",
importPreview: "匯入預覽",
syncPreview: "同步預覽",
targetConnection: "選擇目標 etcd 連線",
targetReadOnly: "目標連線為唯讀,無法套用匯入或同步操作。",
mirrorDelete: "刪除目標端多餘的 Key(鏡像)",
mirrorDeleteWarning: "鏡像模式會刪除來源中不存在的目標 Key。套用前請逐一確認刪除操作。",
preparingPreview: "正在準備固定 Revision 的變更預覽...",
operation: "操作",
reason: "原因",
selectedOperations: "已選擇 {count} 項操作",
applyOperations: "套用操作",
transferApplied: "已套用 {count} 項操作",
operationApplied: "已成功套用",
transferPartiallyApplied: "批次停止前已成功套用 {count} 項:{error}。預覽已重新整理,請只重試剩餘操作。",
transferPartialRefreshFailed: "批次停止前已成功套用 {count} 項:{error}。重新整理預覽也失敗:{previewError}。請關閉並重新開啟預覽後再試。",
targetChangedDuringTransfer: "批次執行期間目標連線已變更",
dashboard: {
title: "etcd 儀表板",
description: "基於目前用戶端可存取端點的觀測結果,不等同於權威的叢集 Quorum 健康檢查。",
autoRefreshOff: "關閉自動重新整理",
refresh: "重新整理",
retry: "再次檢查",
loadFailed: "無法載入 etcd 儀表板",
agentUpgradeTitle: "目前的 etcd Agent 不支援儀表板",
agentUpgradeDescription: "此連線仍使用無法讀取叢集狀態的舊版 Agent。Key 瀏覽與編輯功能仍可正常使用。",
agentUpgradeSteps: "請在驅動管理中更新 etcd Agent,然後中斷並重新連線。完成後選擇「再次檢查」。",
observedHealth: "觀測健康度",
reachableMembers: "可存取成員",
keyCount: "Key 數量",
backendSize: "後端儲存大小",
fragmentation: "碎片率估算",
cluster: "叢集",
revision: "Revision",
leader: "Leader",
noAlarms: "未觀測到活動警報",
prometheusMetrics: "Prometheus 執行指標",
prometheusDescription: "採集 etcd /metrics 的穩定指標;速率類資料由相鄰兩次取樣計算。",
collectedAt: "採集於",
requests: "gRPC 請求速率",
requestFailureRate: "請求失敗率",
proposals: "Raft Proposals",
proposalLag: "已提交與已套用差值",
pending: "等待中",
diskLatency: "磁碟持久化延遲",
walFsync: "WAL fsync 平均",
backendCommit: "Backend commit 平均",
networkTraffic: "用戶端網路流量",
peerTraffic: "Peer 流量",
processResources: "程序資源",
runtime: "Go 執行環境",
goroutines: "goroutines",
leaderChanges: "Leader 變更累計",
failedProposals: "失敗 Proposal 累計",
failedProposalRate: "失敗率",
rateNeedsRefresh: "再次重新整理以計算即時速率",
storageQuota: "後端配額使用率",
uptime: "程序執行時間",
consensusReliability: "共識與可靠性",
leadership: "領導狀態",
knownPeers: "已知 Peer",
proposalState: "Proposal 狀態",
raftFailures: "Raft 失敗",
revisionState: "MVCC Revision",
compactRevision: "壓縮 Revision",
requestLoad: "請求負載",
grpcMethod: "gRPC 方法",
total: "總計",
rate: "速率",
failures: "失敗",
avgLatency: "平均延遲",
storageDisk: "儲存與磁碟",
databaseState: "資料庫狀態",
inUse: "使用中",
openReadTransactions: "開啟的讀取交易",
backgroundTasks: "背景任務",
watchState: "Watch 狀態",
watchers: "Watchers",
watchStreams: "Watch 串流",
events: "事件速率",
slowPending: "緩慢 / 等待中",
leaseState: "Lease 生命週期",
granted: "授予速率",
renewed: "續約速率",
revokedExpired: "撤銷 / 過期",
averageTtl: "平均 TTL",
runtimeResources: "執行環境資源",
memory: "記憶體",
processNetworkGc: "程序網路與 GC",
metricsUnavailable: "Prometheus 指標暫不可用",
metricsUnavailableHint: "叢集狀態仍可正常檢視。DBX 會直接存取目前 etcd endpoint 拼接後的 /metrics,請確認 DBX 應用程式可以存取該位址。",
endpointMember: "端點 / 成員",
role: "角色",
version: "版本",
raftTermApplied: "Raft 任期 / 已套用",
dbSizeInUse: "儲存大小 / 已使用",
latency: "延遲",
status: "狀態",
learner: "Learner",
follower: "Follower",
reachable: "可存取",
unreachable: "不可存取",
loading: "正在載入叢集觀測資料…",
health: {
unknown: "未知",
critical: "嚴重",
warning: "警告",
healthy: "健康",
},
},
},
zookeeper: {
prefixPlaceholder: "路徑前綴,例如 /app/",
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { classifyEtcdDashboardError, sanitizeEtcdDashboardError } from "@/lib/kv/etcdDashboardError";
describe("etcd dashboard errors", () => {
it("recognizes legacy agents that do not implement kv_status", () => {
expect(classifyEtcdDashboardError(new Error("Agent RPC error (-1): Unknown method: kv_status")).kind).toBe("unsupported");
expect(classifyEtcdDashboardError("Agent RPC error (-32601): Method not found: kv_status").kind).toBe("unsupported");
expect(classifyEtcdDashboardError("ETCD_STATUS_UNSUPPORTED: update the driver").kind).toBe("unsupported");
});
it("removes unrelated agent stderr from the user-facing message", () => {
const error = "Agent RPC error (-1): connection refused. recent stderr: SLF4J(W): No SLF4J providers were found. io.netty.resolver.dns.DnsServerAddressStreamProviders warning";
expect(sanitizeEtcdDashboardError(error)).toBe("Agent RPC error (-1): connection refused");
});
it("keeps ordinary request failures as request errors", () => {
const result = classifyEtcdDashboardError(new Error("Agent RPC error (-1): etcdserver: request timed out"));
expect(result).toEqual({
kind: "request",
message: "Agent RPC error (-1): etcdserver: request timed out",
});
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import type { KvPrometheusMetrics } from "@/lib/backend/tauri";
import { counterMapRates, counterRate, histogramAverageMilliseconds, histogramMapAverageMilliseconds, ratePercentage } from "@/lib/kv/etcdDashboardMetrics";
function snapshot(values: Partial<KvPrometheusMetrics>): KvPrometheusMetrics {
return {
available: true,
sourceUrl: "http://localhost:2380/metrics",
grpcMethodRequestsTotal: {},
grpcMethodFailuresTotal: {},
requestDurationSecondsSumByType: {},
requestDurationSecondsCountByType: {},
...values,
};
}
describe("etcd dashboard metrics", () => {
it("converts cumulative counters to rates using adjacent snapshots", () => {
const previous = snapshot({ collectedAtMs: 10_000, grpcRequestsTotal: 100 });
const current = snapshot({ collectedAtMs: 20_000, grpcRequestsTotal: 160 });
expect(counterRate(current, previous, "grpcRequestsTotal")).toBe(6);
});
it("does not report a misleading rate after a counter reset", () => {
const previous = snapshot({ collectedAtMs: 10_000, grpcRequestsTotal: 100 });
const current = snapshot({ collectedAtMs: 20_000, grpcRequestsTotal: 5 });
expect(counterRate(current, previous, "grpcRequestsTotal")).toBeNull();
});
it("uses delta histogram observations after the first snapshot", () => {
const previous = snapshot({
collectedAtMs: 10_000,
walFsyncDurationSecondsSum: 1,
walFsyncDurationSecondsCount: 20,
});
const current = snapshot({
collectedAtMs: 20_000,
walFsyncDurationSecondsSum: 1.2,
walFsyncDurationSecondsCount: 30,
});
expect(histogramAverageMilliseconds(current, previous, "walFsyncDurationSecondsSum", "walFsyncDurationSecondsCount")).toBeCloseTo(20);
});
it("computes a bounded failure percentage", () => {
expect(ratePercentage(2, 20)).toBe(10);
expect(ratePercentage(2, 0)).toBeNull();
expect(ratePercentage(25, 20)).toBe(100);
});
it("calculates rates for labeled counters", () => {
const previous = snapshot({ collectedAtMs: 10_000, grpcMethodRequestsTotal: { Range: 100, Put: 4 } });
const current = snapshot({ collectedAtMs: 20_000, grpcMethodRequestsTotal: { Range: 160, Put: 14 } });
expect(counterMapRates(current, previous, "grpcMethodRequestsTotal")).toEqual({ Range: 6, Put: 1 });
});
it("calculates per-operation latency from labeled histogram deltas", () => {
const previous = snapshot({
collectedAtMs: 10_000,
requestDurationSecondsSumByType: { Range: 1 },
requestDurationSecondsCountByType: { Range: 20 },
});
const current = snapshot({
collectedAtMs: 20_000,
requestDurationSecondsSumByType: { Range: 1.2 },
requestDurationSecondsCountByType: { Range: 30 },
});
expect(histogramMapAverageMilliseconds(current, previous, "requestDurationSecondsSumByType", "requestDurationSecondsCountByType").Range).toBeCloseTo(20);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { isKeyInKvExportScope, kvDirectoryPrefix, kvExportFilenameStem, kvValueByteIdentity } from "@/lib/kv/kvExportScope";
describe("KV export scope", () => {
it("matches a directory itself and descendants without leaking into adjacent prefixes", () => {
const scope = { path: "/dbx", kind: "prefix" as const };
expect(isKeyInKvExportScope("/dbx", scope)).toBe(true);
expect(isKeyInKvExportScope("/dbx/app/name", scope)).toBe(true);
expect(isKeyInKvExportScope("/dbx-other", scope)).toBe(false);
});
it("matches only the selected key for a key export", () => {
const scope = { path: "dbx/key", kind: "key" as const };
expect(isKeyInKvExportScope("dbx/key", scope)).toBe(true);
expect(isKeyInKvExportScope("dbx/key/child", scope)).toBe(false);
});
it("builds stable directory prefixes and safe filenames", () => {
expect(kvDirectoryPrefix("/dbx")).toBe("/dbx/");
expect(kvDirectoryPrefix("dbx/")).toBe("dbx/");
expect(kvExportFilenameStem("/应用 配置/prod")).toBe("应用-配置-prod");
expect(kvExportFilenameStem("/")).toBe("root");
});
it("compares UTF-8 and Base64 representations by their original bytes", () => {
expect(kvValueByteIdentity({ encoding: "utf8", data: "/dbx/app" })).toBe(kvValueByteIdentity({ encoding: "base64", data: "L2RieC9hcHA=" }));
expect(kvValueByteIdentity({ encoding: "utf8", data: "/dbx/app" })).not.toBe(kvValueByteIdentity({ encoding: "utf8", data: "/dbx-other" }));
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { filterKvKeysBySearch, kvKeyMatchesSearch } from "@/lib/kv/kvKeySearch";
describe("KV Key path search", () => {
it("matches the query anywhere in the complete Key path", () => {
const keys = ["/a/b/c", "/dbx/a", "/test/a", "/test/c", "a", "aaa", "bb"].map((key) => ({ key }));
expect(filterKvKeysBySearch(keys, "a").map((key) => key.key)).toEqual(["/a/b/c", "/dbx/a", "/test/a", "a", "aaa"]);
});
it("matches case-insensitively and ignores surrounding whitespace", () => {
expect(kvKeyMatchesSearch("/Apps/Production/Name", " production ")).toBe(true);
expect(kvKeyMatchesSearch("/Apps/Production/Name", "staging")).toBe(false);
});
});
+37 -1
View File
@@ -1,7 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildKvKeyTree, collectKvGroupIds, flattenVisibleKvKeyTree, preserveKvExpandedGroupIds } from "@/lib/kv/kvKeyTree";
import { buildKvKeyTree, collectKvGroupIds, flattenVisibleKvKeyTree, kvKeyTreeNodePath, preserveKvExpandedGroupIds } from "@/lib/kv/kvKeyTree";
describe("kv key tree", () => {
it("represents a key that is also a prefix as one expandable value node", () => {
const tree = buildKvKeyTree([
{ key: "/app", modRevision: "2" },
{ key: "/app/name", modRevision: "3" },
]);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
kind: "group",
label: "app",
key: "/app",
modRevision: "2",
children: [{ kind: "leaf", key: "/app/name" }],
});
});
it("keeps root keys as leaf nodes", () => {
const tree = buildKvKeyTree([{ key: "/plain", version: 2 }, { key: "/" }]);
@@ -9,6 +25,21 @@ describe("kv key tree", () => {
expect(tree[1]).toMatchObject({ kind: "leaf", key: "/plain", version: 2 });
});
it("keeps colliding display keys as distinct raw-byte leaves", () => {
const binaryBytes = { encoding: "base64" as const, data: "/w==" };
const utf8Bytes = { encoding: "utf8" as const, data: "[base64:/w==]" };
const tree = buildKvKeyTree([
{ key: "[base64:/w==]", keyIdentity: "ff", keyBytes: binaryBytes },
{ key: "[base64:/w==]", keyIdentity: "5b6261736536343a2f773d3d5d", keyBytes: utf8Bytes },
]);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({ kind: "group", id: "group:[base64:", label: "[base64:" });
if (tree[0].kind !== "group") throw new Error("expected colliding display keys under their shared virtual directory");
expect(tree[0].children.map((node) => node.id)).toEqual(["leaf:ff", "leaf:5b6261736536343a2f773d3d5d"]);
expect(tree[0].children).toEqual([expect.objectContaining({ keyIdentity: "ff", keyBytes: binaryBytes }), expect.objectContaining({ keyIdentity: "5b6261736536343a2f773d3d5d", keyBytes: utf8Bytes })]);
});
it("groups slash-delimited keys and sorts groups before leaves", () => {
const tree = buildKvKeyTree([
{ key: "/app/config/name", modRevision: 3 },
@@ -45,4 +76,9 @@ describe("kv key tree", () => {
expect([...next]).toEqual(["group:app"]);
expect([...preserveKvExpandedGroupIds(tree, new Set(), true)].sort()).toEqual(["group:app", "group:app\u0000config", "group:service"]);
});
it("preserves whether a virtual directory has a leading slash", () => {
expect(kvKeyTreeNodePath(buildKvKeyTree([{ key: "/app/config/name" }])[0])).toBe("/app");
expect(kvKeyTreeNodePath(buildKvKeyTree([{ key: "app/config/name" }])[0])).toBe("app");
});
});
+30 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { KvGetResponse } from "@/lib/backend/api";
import { decideKvMetadataRefresh, knownKvLeaseKeys, KvListRequestGuard, mergeKvKeyMetadata, mergeKvValueRefresh, nextKvLeaseRefreshDelay, removeMissingKvKey, updateKvResponseTtl } from "@/lib/kv/kvMetadataRefresh";
import { decideKvMetadataRefresh, hasPositiveKvLease, knownKvLeaseSummaries, KvListRequestGuard, mergeKvKeyMetadata, mergeKvValueRefresh, nextKvLeaseRefreshDelay, removeMissingKvKey, updateKvResponseTtl } from "@/lib/kv/kvMetadataRefresh";
function found(metadata: NonNullable<KvGetResponse["metadata"]>): KvGetResponse {
return {
@@ -83,10 +83,26 @@ describe("decideKvMetadataRefresh", () => {
});
it("tracks only known leased keys and excludes the selected key", () => {
const keys = [{ key: "/no-lease", lease: 0 }, { key: "/leased", lease: 10 }, { key: "/selected", lease: 20 }, { key: "/unknown" }];
const keys = [{ key: "/no-lease", lease: 0 }, { key: "/leased", lease: "10" }, { key: "/selected", lease: 20 }, { key: "/unknown" }];
expect(knownKvLeaseKeys(keys, "/selected")).toEqual(["/leased"]);
expect(knownKvLeaseKeys(keys, null)).toEqual(["/leased", "/selected"]);
expect(knownKvLeaseSummaries(keys, "/selected")).toEqual([keys[1]]);
expect(knownKvLeaseSummaries(keys, null)).toEqual([keys[1], keys[2]]);
});
it("excludes only the selected raw-byte identity from lease refresh", () => {
const keys = [
{ key: "[base64:/w==]", keyIdentity: "ff", lease: 10 },
{ key: "[base64:/w==]", keyIdentity: "5b6261736536343a2f773d3d5d", lease: 20 },
];
expect(knownKvLeaseSummaries(keys, "ff")).toEqual([keys[1]]);
});
it("recognizes positive lease IDs in the string-based int64 transport", () => {
expect(hasPositiveKvLease("9007199254740993")).toBe(true);
expect(hasPositiveKvLease(10)).toBe(true);
expect(hasPositiveKvLease("0")).toBe(false);
expect(hasPositiveKvLease("")).toBe(false);
});
it("merges refreshed metadata without replacing the known value size and removes expired keys", () => {
@@ -102,6 +118,16 @@ describe("decideKvMetadataRefresh", () => {
expect(mergeKvKeyMetadata(keys, "/expired", { found: false })).toEqual([{ key: "/leased", lease: 10, ttl: 4, valueSize: 12 }]);
});
it("refreshes and removes only the matching raw-byte identity when display keys collide", () => {
const keys = [
{ key: "[base64:/w==]", keyIdentity: "ff", lease: 10, ttl: 4, valueSize: 12 },
{ key: "[base64:/w==]", keyIdentity: "5b6261736536343a2f773d3d5d", lease: 20, ttl: 8, valueSize: 24 },
];
expect(mergeKvKeyMetadata(keys, "[base64:/w==]", found({ lease: 11, ttl: 3, modRevision: 5 }), "ff")).toEqual([{ key: "[base64:/w==]", keyIdentity: "ff", lease: 11, ttl: 3, modRevision: 5, valueSize: 12 }, keys[1]]);
expect(removeMissingKvKey(keys, "[base64:/w==]", "ff")).toEqual([keys[1]]);
});
it("backs off after failures and resets after a successful cycle", () => {
expect(nextKvLeaseRefreshDelay(2000, true)).toBe(4000);
expect(nextKvLeaseRefreshDelay(4000, true)).toBe(8000);
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { classifyKvMutationError, sanitizeKvMutationError } from "@/lib/kv/kvMutationError";
describe("KV mutation errors", () => {
const noisyConflict = "Agent RPC error (-1): ETCD_CAS_CONFLICT: key changed after it was loaded. recent stderr: SLF4J(W): No SLF4J providers were found. io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider warning";
it("turns a duplicate create conflict into an actionable message", () => {
expect(
classifyKvMutationError(noisyConflict, true, {
keyAlreadyExists: "同名 Key 已存在,请更换名称,或编辑现有 Key。",
}),
).toEqual({
kind: "keyAlreadyExists",
message: "同名 Key 已存在,请更换名称,或编辑现有 Key。",
});
});
it("keeps edit conflicts distinct from duplicate creates", () => {
expect(classifyKvMutationError(noisyConflict, false, { conflict: "Key 已被修改,请刷新后重试。" })).toEqual({
kind: "conflict",
message: "Key 已被修改,请刷新后重试。",
});
});
it("removes RPC wrappers and unrelated Agent stderr from request errors", () => {
expect(sanitizeKvMutationError("Agent RPC error (-1): etcdserver: request timed out. recent stderr: noisy")).toBe("etcdserver: request timed out");
});
});
@@ -1,15 +1,22 @@
import { describe, expect, it } from "vitest";
import { refreshedKvSelectionKey } from "@/lib/kv/kvRefreshSelection";
import { refreshedKvSelectionSummary } from "@/lib/kv/kvRefreshSelection";
import type { KvKeySummary } from "@/lib/backend/api";
describe("kv refresh selection", () => {
const keys: KvKeySummary[] = [{ key: "/app/config" }, { key: "/app/feature" }];
it("keeps the previously selected key when it still exists after refresh", () => {
expect(refreshedKvSelectionKey("/app/config", keys)).toBe("/app/config");
expect(refreshedKvSelectionSummary("/app/config", keys)).toEqual({ key: "/app/config" });
});
it("clears the selected key when it no longer exists after refresh", () => {
expect(refreshedKvSelectionKey("/app/missing", keys)).toBeNull();
expect(refreshedKvSelectionSummary("/app/missing", keys)).toBeNull();
});
it("does not switch to a colliding display key when the selected byte identity disappears", () => {
const collision: KvKeySummary[] = [{ key: "[base64:/w==]", keyIdentity: "5b6261736536343a2f773d3d5d" }];
expect(refreshedKvSelectionSummary("ff", collision)).toBeNull();
expect(refreshedKvSelectionSummary("5b6261736536343a2f773d3d5d", collision)).toEqual(collision[0]);
});
});
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseOptionalTtl } from "@/lib/kv/kvTtl";
import { parseKvLeaseId, parseOptionalTtl } from "@/lib/kv/kvTtl";
describe("parseOptionalTtl", () => {
it("treats an empty TTL as a permanent key", () => {
@@ -23,3 +23,18 @@ describe("parseOptionalTtl", () => {
expect(parseOptionalTtl("9007199254740992")).toEqual({ ok: false });
});
});
describe("parseKvLeaseId", () => {
it("preserves lease IDs above JavaScript's safe integer limit", () => {
expect(parseKvLeaseId("9007199254740993")).toBe("9007199254740993");
expect(parseKvLeaseId("9223372036854775807")).toBe("9223372036854775807");
});
it("rejects empty, non-positive, decimal, and overflowing lease IDs", () => {
expect(parseKvLeaseId("")).toBeNull();
expect(parseKvLeaseId("0")).toBeNull();
expect(parseKvLeaseId("-1")).toBeNull();
expect(parseKvLeaseId("1.5")).toBeNull();
expect(parseKvLeaseId("9223372036854775808")).toBeNull();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { detectKvValueFormat, formatKvValue, validateKvValue } from "@/lib/kv/kvValueFormat";
describe("kv value formats", () => {
it("detects structured UTF-8 values", () => {
expect(detectKvValueFormat('{"enabled":true}')).toBe("json");
expect(detectKvValueFormat("service:\n port: 8080")).toBe("yaml");
expect(detectKvValueFormat("port: 8080\nenabled: true")).toBe("yaml");
expect(detectKvValueFormat("<root><value>1</value></root>")).toBe("xml");
expect(detectKvValueFormat("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app")).toBe("kubernetes");
expect(detectKvValueFormat("SELECT * FROM users;")).toBe("sql");
expect(detectKvValueFormat("server.port=8080\nfeature.enabled=true")).toBe("properties");
expect(detectKvValueFormat("#!/usr/bin/env bash\nset -euo pipefail")).toBe("shell");
expect(detectKvValueFormat("FROM alpine:3.22\nRUN echo ok")).toBe("dockerfile");
expect(detectKvValueFormat("server {\n listen 80;\n}")).toBe("nginx");
expect(detectKvValueFormat("hello")).toBe("text");
});
it("validates and formats JSON", () => {
expect(validateKvValue('{"ok":true}', "json")).toBeNull();
expect(validateKvValue("{", "json")).toBeTruthy();
expect(formatKvValue('{"ok":true}', "json")).toBe('{\n "ok": true\n}');
});
it("validates base64 without decoding it through UTF-8", () => {
expect(validateKvValue("AP+A", "base64")).toBeNull();
expect(validateKvValue("***", "base64")).toBeTruthy();
});
it("validates configuration-oriented formats", () => {
expect(validateKvValue("apiVersion: v1\nkind: ConfigMap", "kubernetes")).toBeNull();
expect(validateKvValue("kind: ConfigMap", "kubernetes")).toContain("apiVersion");
expect(validateKvValue("server.port=8080", "properties")).toBeNull();
expect(validateKvValue("server.port", "properties")).toContain("Properties");
expect(validateKvValue("FROM alpine\nRUN echo ok", "dockerfile")).toBeNull();
expect(validateKvValue("UNKNOWN value", "dockerfile")).toContain("Dockerfile");
expect(validateKvValue("server {\n listen 80;\n}", "nginx")).toBeNull();
expect(validateKvValue("server {", "nginx")).toContain("unbalanced");
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { alignedSidebarCommentLabelWidths, sidebarTreeNaturalContentWidth, trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { alignedSidebarCommentLabelWidths, canTreeNodeShowExpander, sidebarTreeNaturalContentWidth, trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
describe("sidebar tree item layout", () => {
it("keeps a table row constrained when it displays a comment", () => {
@@ -11,6 +11,11 @@ describe("sidebar tree item layout", () => {
expect(treeLabelWidthClass({ fullWidth: false, hasTrailingComment: true })).toBe("min-w-0 flex-1 truncate");
});
it("renders etcd Keys and Dashboard as aligned leaf actions", () => {
expect(canTreeNodeShowExpander({ type: "etcd-root", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-dashboard", childCount: 0 })).toBe(false);
});
it("aligns comments to the longest sibling name without crossing parent groups", () => {
const widths = alignedSidebarCommentLabelWidths([
{ id: "tables", depth: 1, alignable: false, hasComment: false, labelWidth: 0 },
+12 -1
View File
@@ -412,6 +412,9 @@ export const etcdSupportsTtl = forward("etcdSupportsTtl");
export const etcdGet = forward("etcdGet");
export const etcdPut = forward("etcdPut");
export const etcdDelete = forward("etcdDelete");
export const etcdRename = forward("etcdRename");
export const etcdHistory = forward("etcdHistory");
export const etcdStatus = forward("etcdStatus");
// ZooKeeper
export const zookeeperListPrefix = forward("zookeeperListPrefix");
@@ -620,18 +623,26 @@ export type {
RedisSlowlogEntry,
RedisNodeEndpoint,
KvValueEncoding,
KvInt64,
KvValue,
KvKeyMetadata,
KvKeySummary,
KvListPrefixResponse,
KvListPrefixOptions,
KvGetResponse,
KvGetOptions,
KvGetResponse,
KvWriteMode,
KvCreateMode,
KvPutOptions,
KvPutResponse,
KvDeleteOptions,
KvDeleteResponse,
KvHistoryEventType,
KvHistoryEvent,
KvHistoryResponse,
KvStatusMember,
KvPrometheusMetrics,
KvStatusResponse,
DocumentQueryResult,
MongoDocumentResult,
HistoryEntry,
+24 -5
View File
@@ -72,6 +72,7 @@ import type {
RedisCommandResult,
RedisSlowlogEntry,
RedisNodeEndpoint,
KvInt64,
KvValue,
KvListPrefixResponse,
KvListPrefixOptions,
@@ -79,7 +80,10 @@ import type {
KvGetOptions,
KvPutOptions,
KvPutResponse,
KvDeleteOptions,
KvDeleteResponse,
KvHistoryResponse,
KvStatusResponse,
DocumentQueryResult,
MongoDocumentResult,
MongoCollectionStatsResult,
@@ -2054,8 +2058,8 @@ export async function redisClusterMasterNodes(connectionId: string): Promise<Red
// etcd
// ---------------------------------------------------------------------------
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null): Promise<KvListPrefixResponse> {
return post("/api/etcd/list-prefix", { connectionId, prefix, limit, continuation });
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null, options?: KvListPrefixOptions | null): Promise<KvListPrefixResponse> {
return post("/api/etcd/list-prefix", { connectionId, prefix, limit, continuation, revision: options?.revision ?? null, includeValues: options?.includeValues ?? null });
}
export async function etcdSupportsTtl(connectionId: string): Promise<boolean> {
@@ -2063,7 +2067,7 @@ export async function etcdSupportsTtl(connectionId: string): Promise<boolean> {
}
export async function etcdGet(connectionId: string, key: string, options?: KvGetOptions | null): Promise<KvGetResponse> {
return post("/api/etcd/get", { connectionId, key, metadataOnly: options?.metadataOnly ?? null });
return post("/api/etcd/get", { connectionId, key, keyBytes: options?.keyBytes ?? null, revision: options?.revision ?? null, metadataOnly: options?.metadataOnly ?? null });
}
export async function etcdPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | number | null): Promise<KvPutResponse> {
@@ -2076,11 +2080,26 @@ export async function etcdPut(connectionId: string, key: string, value: KvValue,
lease: legacyLease ?? putOptions?.lease ?? null,
ttl: putOptions?.ttl ?? null,
preserveLease: putOptions?.preserveLease ?? null,
keyBytes: putOptions?.keyBytes ?? null,
expectedModRevision: putOptions?.expectedModRevision ?? null,
expectedCreateRevision: putOptions?.expectedCreateRevision ?? null,
});
}
export async function etcdDelete(connectionId: string, key: string): Promise<KvDeleteResponse> {
return post("/api/etcd/delete", { connectionId, key });
export async function etcdDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise<KvDeleteResponse> {
return post("/api/etcd/delete", { connectionId, key, keyBytes: options?.keyBytes ?? null, expectedModRevision: options?.expectedModRevision ?? null });
}
export async function etcdRename(connectionId: string, request: { key: string; keyBytes?: KvValue | null; newKey: string; expectedModRevision?: KvInt64 | null }): Promise<{ renamed: boolean; revision?: KvInt64 | null }> {
return post("/api/etcd/rename", { connectionId, request });
}
export async function etcdHistory(connectionId: string, request: { key: string; keyBytes?: KvValue | null; startRevision?: KvInt64 | null; endRevision?: KvInt64 | null; limit: number }): Promise<KvHistoryResponse> {
return post("/api/etcd/history", { connectionId, request });
}
export async function etcdStatus(connectionId: string): Promise<KvStatusResponse> {
return post("/api/etcd/status", { connectionId });
}
// ---------------------------------------------------------------------------
+174 -12
View File
@@ -1770,6 +1770,7 @@ export async function redisClusterMasterNodes(connectionId: string): Promise<Red
// --- etcd ---
export type KvValueEncoding = "utf8" | "base64";
export type KvInt64 = string;
export interface KvValue {
encoding: KvValueEncoding;
@@ -1777,10 +1778,10 @@ export interface KvValue {
}
export interface KvKeyMetadata {
createRevision?: number | null;
modRevision?: number | null;
version?: number | null;
lease?: number | null;
createRevision?: KvInt64 | number | null;
modRevision?: KvInt64 | number | null;
version?: KvInt64 | number | null;
lease?: KvInt64 | number | null;
ttl?: number | null;
valueSize?: number | null;
czxid?: number | null;
@@ -1797,27 +1798,36 @@ export interface KvKeyMetadata {
export interface KvKeySummary extends KvKeyMetadata {
key: string;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
value?: KvValue | null;
}
export interface KvListPrefixResponse {
keys: KvKeySummary[];
continuation?: string | null;
revision?: number | null;
revision?: KvInt64 | number | null;
}
export interface KvListPrefixOptions {
recursive?: boolean | null;
revision?: KvInt64 | null;
includeValues?: boolean | null;
}
export interface KvGetResponse {
found: boolean;
key?: string | null;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
value?: KvValue | null;
metadata?: KvKeyMetadata | null;
}
export interface KvGetOptions {
metadataOnly?: boolean | null;
keyBytes?: KvValue | null;
revision?: KvInt64 | null;
}
export interface KvPutResponse {
@@ -1832,20 +1842,159 @@ export type KvWriteMode = "upsert" | "create" | "update";
export type KvCreateMode = "persistent" | "ephemeral" | "persistent_sequential" | "ephemeral_sequential";
export interface KvPutOptions {
lease?: number | null;
lease?: KvInt64 | number | null;
ttl?: number | null;
preserveLease?: boolean | null;
writeMode?: KvWriteMode | null;
createMode?: KvCreateMode | null;
keyBytes?: KvValue | null;
expectedModRevision?: KvInt64 | null;
expectedCreateRevision?: KvInt64 | null;
}
export interface KvDeleteOptions {
keyBytes?: KvValue | null;
expectedModRevision?: KvInt64 | null;
}
export interface KvDeleteResponse {
deleted: number;
revision?: number | null;
revision?: KvInt64 | number | null;
}
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null): Promise<KvListPrefixResponse> {
return invoke("etcd_list_prefix", { connectionId, prefix, limit, continuation });
export type KvHistoryEventType = "put" | "delete";
export interface KvHistoryEvent {
eventType: KvHistoryEventType;
revision: KvInt64;
value?: KvValue | null;
previousValue?: KvValue | null;
metadata?: KvKeyMetadata | null;
}
export interface KvHistoryResponse {
events: KvHistoryEvent[];
observedRevision: KvInt64;
truncated: boolean;
}
export interface KvStatusMember {
endpoint: string;
memberId?: KvInt64 | null;
name?: string | null;
version?: string | null;
leaderId?: KvInt64 | null;
revision?: KvInt64 | null;
raftTerm?: KvInt64 | null;
raftIndex?: KvInt64 | null;
raftAppliedIndex?: KvInt64 | null;
dbSize?: KvInt64 | null;
dbSizeInUse?: KvInt64 | null;
learner: boolean;
reachable: boolean;
latencyMs?: number | null;
errors: string[];
}
export interface KvPrometheusMetrics {
available: boolean;
sourceUrl?: string | null;
error?: string | null;
collectedAtMs?: number | null;
sampleCount?: number | null;
serverVersion?: string | null;
clusterVersion?: string | null;
goVersion?: string | null;
authRevision?: number | null;
hasLeader?: number | null;
isLeader?: number | null;
leaderChangesTotal?: number | null;
proposalsCommittedTotal?: number | null;
proposalsAppliedTotal?: number | null;
proposalsPending?: number | null;
proposalsFailedTotal?: number | null;
grpcRequestsTotal?: number | null;
grpcFailuresTotal?: number | null;
grpcMethodRequestsTotal: Record<string, number>;
grpcMethodFailuresTotal: Record<string, number>;
requestDurationSecondsSumByType: Record<string, number>;
requestDurationSecondsCountByType: Record<string, number>;
mvccPutTotal?: number | null;
mvccDeleteTotal?: number | null;
mvccRangeTotal?: number | null;
mvccTxnTotal?: number | null;
mvccCurrentRevision?: number | null;
mvccCompactRevision?: number | null;
mvccKeysTotal?: number | null;
mvccEventsTotal?: number | null;
mvccPendingEventsTotal?: number | null;
mvccSlowWatcherTotal?: number | null;
mvccWatchStreamTotal?: number | null;
mvccWatcherTotal?: number | null;
mvccTotalPutSizeBytes?: number | null;
openReadTransactions?: number | null;
leaseGrantedTotal?: number | null;
leaseRenewedTotal?: number | null;
leaseRevokedTotal?: number | null;
leaseExpiredTotal?: number | null;
leaseTtlSecondsSum?: number | null;
leaseTtlSecondsCount?: number | null;
clientReceivedBytesTotal?: number | null;
clientSentBytesTotal?: number | null;
peerReceivedBytesTotal?: number | null;
peerSentBytesTotal?: number | null;
peerReceivedFailuresTotal?: number | null;
peerSentFailuresTotal?: number | null;
walFsyncDurationSecondsSum?: number | null;
walFsyncDurationSecondsCount?: number | null;
walWriteBytesTotal?: number | null;
walWriteDurationSecondsSum?: number | null;
walWriteDurationSecondsCount?: number | null;
backendCommitDurationSecondsSum?: number | null;
backendCommitDurationSecondsCount?: number | null;
backendSnapshotDurationSecondsSum?: number | null;
backendSnapshotDurationSecondsCount?: number | null;
backendDefragDurationSecondsSum?: number | null;
backendDefragDurationSecondsCount?: number | null;
diskDefragInflight?: number | null;
snapshotApplyInProgress?: number | null;
quotaBackendBytes?: number | null;
knownPeers?: number | null;
heartbeatSendFailuresTotal?: number | null;
readIndexesFailedTotal?: number | null;
slowApplyTotal?: number | null;
slowReadIndexesTotal?: number | null;
healthSuccessTotal?: number | null;
healthFailuresTotal?: number | null;
residentMemoryBytes?: number | null;
virtualMemoryBytes?: number | null;
cpuSecondsTotal?: number | null;
processStartTimeSeconds?: number | null;
processReceivedBytesTotal?: number | null;
processTransmittedBytesTotal?: number | null;
openFds?: number | null;
maxFds?: number | null;
goroutines?: number | null;
goThreads?: number | null;
goMaxProcs?: number | null;
goHeapAllocBytes?: number | null;
goHeapInuseBytes?: number | null;
goHeapSysBytes?: number | null;
goHeapObjects?: number | null;
goNextGcBytes?: number | null;
goGcDurationSecondsSum?: number | null;
goGcDurationSecondsCount?: number | null;
dbSizeMetricBytes?: number | null;
dbSizeInUseMetricBytes?: number | null;
}
export interface KvStatusResponse {
clusterId?: KvInt64 | null;
revision?: KvInt64 | null;
leaderId?: KvInt64 | null;
keyCount?: KvInt64 | null;
alarms: string[];
members: KvStatusMember[];
metrics?: KvPrometheusMetrics | null;
}
export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null, options?: KvListPrefixOptions | null): Promise<KvListPrefixResponse> {
return invoke("etcd_list_prefix", { connectionId, prefix, limit, continuation, revision: options?.revision ?? null, includeValues: options?.includeValues ?? null });
}
export async function etcdSupportsTtl(connectionId: string): Promise<boolean> {
@@ -1853,7 +2002,7 @@ export async function etcdSupportsTtl(connectionId: string): Promise<boolean> {
}
export async function etcdGet(connectionId: string, key: string, options?: KvGetOptions | null): Promise<KvGetResponse> {
return invoke("etcd_get", { connectionId, key, metadataOnly: options?.metadataOnly ?? null });
return invoke("etcd_get", { connectionId, key, keyBytes: options?.keyBytes ?? null, revision: options?.revision ?? null, metadataOnly: options?.metadataOnly ?? null });
}
export async function etcdPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | number | null): Promise<KvPutResponse> {
@@ -1866,11 +2015,24 @@ export async function etcdPut(connectionId: string, key: string, value: KvValue,
lease: legacyLease ?? putOptions?.lease ?? null,
ttl: putOptions?.ttl ?? null,
preserveLease: putOptions?.preserveLease ?? null,
keyBytes: putOptions?.keyBytes ?? null,
expectedModRevision: putOptions?.expectedModRevision ?? null,
expectedCreateRevision: putOptions?.expectedCreateRevision ?? null,
});
}
export async function etcdDelete(connectionId: string, key: string): Promise<KvDeleteResponse> {
return invoke("etcd_delete", { connectionId, key });
export async function etcdDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise<KvDeleteResponse> {
return invoke("etcd_delete", { connectionId, key, keyBytes: options?.keyBytes ?? null, expectedModRevision: options?.expectedModRevision ?? null });
}
export async function etcdRename(connectionId: string, request: { key: string; keyBytes?: KvValue | null; newKey: string; expectedModRevision?: KvInt64 | null }): Promise<{ renamed: boolean; revision?: KvInt64 | null }> {
return invoke("etcd_rename", { connectionId, request });
}
export async function etcdHistory(connectionId: string, request: { key: string; keyBytes?: KvValue | null; startRevision?: KvInt64 | null; endRevision?: KvInt64 | null; limit: number }): Promise<KvHistoryResponse> {
return invoke("etcd_history", { connectionId, request });
}
export async function etcdStatus(connectionId: string): Promise<KvStatusResponse> {
return invoke("etcd_status", { connectionId });
}
// --- ZooKeeper ---
@@ -0,0 +1,28 @@
export type EtcdDashboardErrorKind = "unsupported" | "request";
export interface EtcdDashboardError {
kind: EtcdDashboardErrorKind;
message: string;
}
const unsupportedStatusPatterns = ["etcd_status_unsupported", "unknown method: kv_status", "method not found: kv_status", "method not found: `kv_status`"];
function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error || "");
}
export function sanitizeEtcdDashboardError(error: unknown): string {
const raw = errorText(error).trim();
const withoutStderr = raw.split(/\s*(?:\.\s*)?recent stderr\s*:/i, 1)[0]?.trim() || raw;
return withoutStderr.replace(/^ETCD_STATUS_UNSUPPORTED:\s*/i, "").slice(0, 600);
}
export function classifyEtcdDashboardError(error: unknown): EtcdDashboardError {
const raw = errorText(error);
const normalized = raw.toLowerCase();
return {
kind: unsupportedStatusPatterns.some((pattern) => normalized.includes(pattern)) ? "unsupported" : "request",
message: sanitizeEtcdDashboardError(error),
};
}
@@ -0,0 +1,95 @@
import type { KvPrometheusMetrics } from "@/lib/backend/tauri";
type NumericMetricKey = Exclude<
{
[K in keyof KvPrometheusMetrics]: KvPrometheusMetrics[K] extends number | null | undefined ? K : never;
}[keyof KvPrometheusMetrics],
undefined
>;
type MetricMapKey = Exclude<
{
[K in keyof KvPrometheusMetrics]: KvPrometheusMetrics[K] extends Record<string, number> ? K : never;
}[keyof KvPrometheusMetrics],
undefined
>;
function finiteMetric(metrics: KvPrometheusMetrics | null | undefined, key: NumericMetricKey): number | null {
const value = metrics?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function comparableWindowSeconds(current: KvPrometheusMetrics, previous: KvPrometheusMetrics | null | undefined): number | null {
if (!previous || current.sourceUrl !== previous.sourceUrl) return null;
const currentTime = finiteMetric(current, "collectedAtMs");
const previousTime = finiteMetric(previous, "collectedAtMs");
if (currentTime == null || previousTime == null || currentTime <= previousTime) return null;
return (currentTime - previousTime) / 1000;
}
export function counterRate(current: KvPrometheusMetrics | null | undefined, previous: KvPrometheusMetrics | null | undefined, key: NumericMetricKey): number | null {
if (!current?.available || !previous?.available) return null;
const seconds = comparableWindowSeconds(current, previous);
const currentValue = finiteMetric(current, key);
const previousValue = finiteMetric(previous, key);
if (seconds == null || currentValue == null || previousValue == null || currentValue < previousValue) return null;
return (currentValue - previousValue) / seconds;
}
export function histogramAverageMilliseconds(current: KvPrometheusMetrics | null | undefined, previous: KvPrometheusMetrics | null | undefined, sumKey: NumericMetricKey, countKey: NumericMetricKey): number | null {
if (!current?.available) return null;
const currentSum = finiteMetric(current, sumKey);
const currentCount = finiteMetric(current, countKey);
if (currentSum == null || currentCount == null || currentCount <= 0) return null;
const previousSum = finiteMetric(previous, sumKey);
const previousCount = finiteMetric(previous, countKey);
const hasComparableWindow = comparableWindowSeconds(current, previous) != null;
if (hasComparableWindow && previousSum != null && previousCount != null && currentSum >= previousSum && currentCount > previousCount) {
return ((currentSum - previousSum) / (currentCount - previousCount)) * 1000;
}
return (currentSum / currentCount) * 1000;
}
export function ratePercentage(numerator: number | null, denominator: number | null): number | null {
if (numerator == null || denominator == null || denominator <= 0) return null;
return Math.min(100, Math.max(0, (numerator / denominator) * 100));
}
export function counterMapRates(current: KvPrometheusMetrics | null | undefined, previous: KvPrometheusMetrics | null | undefined, key: MetricMapKey): Record<string, number> {
if (!current?.available || !previous?.available) return {};
const seconds = comparableWindowSeconds(current, previous);
const currentValues = current[key];
const previousValues = previous[key];
if (seconds == null || !currentValues || !previousValues) return {};
return Object.fromEntries(
Object.entries(currentValues).flatMap(([name, currentValue]) => {
const previousValue = previousValues[name];
return Number.isFinite(currentValue) && Number.isFinite(previousValue) && currentValue >= previousValue ? [[name, (currentValue - previousValue) / seconds]] : [];
}),
);
}
export function histogramMapAverageMilliseconds(current: KvPrometheusMetrics | null | undefined, previous: KvPrometheusMetrics | null | undefined, sumKey: MetricMapKey, countKey: MetricMapKey): Record<string, number> {
if (!current?.available) return {};
const currentSums = current[sumKey];
const currentCounts = current[countKey];
if (!currentSums || !currentCounts) return {};
const previousSums = previous?.[sumKey];
const previousCounts = previous?.[countKey];
const useDelta = comparableWindowSeconds(current, previous) != null;
return Object.fromEntries(
Object.entries(currentSums).flatMap(([name, currentSum]) => {
const currentCount = currentCounts[name];
if (!Number.isFinite(currentSum) || !Number.isFinite(currentCount) || currentCount <= 0) return [];
const previousSum = previousSums?.[name];
const previousCount = previousCounts?.[name];
if (useDelta && Number.isFinite(previousSum) && Number.isFinite(previousCount) && currentSum >= previousSum! && currentCount > previousCount!) {
return [[name, ((currentSum - previousSum!) / (currentCount - previousCount!)) * 1000]];
}
return [[name, (currentSum / currentCount) * 1000]];
}),
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { KvValue } from "@/lib/backend/api";
export type KvExportScopeKind = "key" | "prefix";
export interface KvExportScopeRequest {
path: string;
kind: KvExportScopeKind;
keyBytes?: KvValue | null;
}
export function kvDirectoryPrefix(path: string): string {
if (!path || path === "/") return path;
return `${path.replace(/\/+$/, "")}/`;
}
export function isKeyInKvExportScope(key: string, request: KvExportScopeRequest): boolean {
if (request.kind === "key") return key === request.path;
return key === request.path || key.startsWith(kvDirectoryPrefix(request.path));
}
export function kvExportFilenameStem(path: string): string {
const normalized = path
.split("/")
.filter(Boolean)
.join("-")
.replace(/[<>:"\\|?*\u0000-\u001f]/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return normalized.slice(0, 80) || "root";
}
export function kvValueByteIdentity(value: KvValue): string {
const bytes = value.encoding === "utf8" ? new TextEncoder().encode(value.data) : Uint8Array.from(atob(value.data.replace(/\s+/g, "")), (character) => character.charCodeAt(0));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
+14
View File
@@ -0,0 +1,14 @@
import type { KvKeySummary } from "@/lib/backend/api";
export function normalizeKvKeySearchQuery(query: string): string {
return query.trim().toLocaleLowerCase();
}
export function kvKeyMatchesSearch(key: string, query: string): boolean {
const normalized = normalizeKvKeySearchQuery(query);
return !normalized || key.toLocaleLowerCase().includes(normalized);
}
export function filterKvKeysBySearch(keys: readonly KvKeySummary[], query: string): KvKeySummary[] {
return keys.filter((key) => kvKeyMatchesSearch(key.key, query));
}
+76 -10
View File
@@ -1,15 +1,17 @@
import type { KvKeySummary } from "@/lib/backend/api";
import type { KvKeySummary, KvValue } from "@/lib/backend/api";
export interface KvKeyTreeLeafNode {
kind: "leaf";
id: string;
label: string;
key: string;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
pathSegments: string[];
createRevision?: number | null;
modRevision?: number | null;
version?: number | null;
lease?: number | null;
createRevision?: string | number | null;
modRevision?: string | number | null;
version?: string | number | null;
lease?: string | number | null;
valueSize?: number | null;
}
@@ -19,6 +21,14 @@ export interface KvKeyTreeGroupNode {
label: string;
pathSegments: string[];
children: KvKeyTreeNode[];
key?: string;
keyIdentity?: string | null;
keyBytes?: KvValue | null;
createRevision?: string | number | null;
modRevision?: string | number | null;
version?: string | number | null;
lease?: string | number | null;
valueSize?: number | null;
}
export type KvKeyTreeNode = KvKeyTreeLeafNode | KvKeyTreeGroupNode;
@@ -36,8 +46,12 @@ function groupId(pathSegments: string[]): string {
return `group:${pathSegments.join("\u0000")}`;
}
function leafId(key: string): string {
return `leaf:${key}`;
function summaryIdentity(key: KvKeySummary): string {
return key.keyIdentity ?? key.key;
}
function leafId(key: KvKeySummary): string {
return `leaf:${summaryIdentity(key)}`;
}
function sortNodes(nodes: KvKeyTreeNode[]): KvKeyTreeNode[] {
@@ -58,9 +72,11 @@ export function buildKvKeyTree(keys: KvKeySummary[]): KvKeyTreeNode[] {
if (segments.length <= 1) {
root.push({
kind: "leaf",
id: leafId(key.key),
id: leafId(key),
label: segments[0] || key.key || "/",
key: key.key,
keyIdentity: key.keyIdentity,
keyBytes: key.keyBytes,
pathSegments: segments,
createRevision: key.createRevision,
modRevision: key.modRevision,
@@ -78,18 +94,49 @@ export function buildKvKeyTree(keys: KvKeySummary[]): KvKeyTreeNode[] {
const id = groupId(groupSegments);
let group = groups.get(id);
if (!group) {
group = { kind: "group", id, label: segment, pathSegments: [...groupSegments], children: [] };
const leafIndex = current.findIndex((candidate) => candidate.kind === "leaf" && candidate.pathSegments.join("\u0000") === groupSegments.join("\u0000"));
const existingLeaf = leafIndex >= 0 ? (current.splice(leafIndex, 1)[0] as KvKeyTreeLeafNode) : null;
group = {
kind: "group",
id,
label: segment,
pathSegments: [...groupSegments],
children: [],
key: existingLeaf?.key,
keyIdentity: existingLeaf?.keyIdentity,
keyBytes: existingLeaf?.keyBytes,
createRevision: existingLeaf?.createRevision,
modRevision: existingLeaf?.modRevision,
version: existingLeaf?.version,
lease: existingLeaf?.lease,
valueSize: existingLeaf?.valueSize,
};
groups.set(id, group);
current.push(group);
}
current = group.children;
}
const existingGroup = groups.get(groupId(segments));
if (existingGroup) {
existingGroup.key = key.key;
existingGroup.keyIdentity = key.keyIdentity;
existingGroup.keyBytes = key.keyBytes;
existingGroup.createRevision = key.createRevision;
existingGroup.modRevision = key.modRevision;
existingGroup.version = key.version;
existingGroup.lease = key.lease;
existingGroup.valueSize = key.valueSize;
continue;
}
current.push({
kind: "leaf",
id: leafId(key.key),
id: leafId(key),
label: segments[segments.length - 1],
key: key.key,
keyIdentity: key.keyIdentity,
keyBytes: key.keyBytes,
pathSegments: segments,
createRevision: key.createRevision,
modRevision: key.modRevision,
@@ -134,3 +181,22 @@ export function flattenVisibleKvKeyTree(nodes: KvKeyTreeNode[], expandedGroupIds
}
return rows;
}
export function kvKeyTreeNodePath(node: KvKeyTreeNode): string {
if (node.kind === "leaf") return node.key;
if (node.key) return node.key;
const descendantKey = firstDescendantKey(node);
const joined = node.pathSegments.join("/");
return descendantKey?.startsWith("/") ? `/${joined}` : joined;
}
function firstDescendantKey(node: KvKeyTreeGroupNode): string | null {
for (const child of node.children) {
if (child.kind === "leaf") return child.key;
if (child.key) return child.key;
const nested = firstDescendantKey(child);
if (nested) return nested;
}
return null;
}
+16 -7
View File
@@ -45,18 +45,27 @@ export function mergeKvValueRefresh(current: KvGetResponse | null, incoming: KvG
return current;
}
export function removeMissingKvKey(keys: readonly KvKeySummary[], missingKey: string): KvKeySummary[] {
return keys.filter((key) => key.key !== missingKey);
function matchesKvKey(summary: KvKeySummary, key: string, keyIdentity?: string | null): boolean {
return keyIdentity != null ? (summary.keyIdentity ?? summary.key) === keyIdentity : summary.key === key;
}
export function knownKvLeaseKeys(keys: readonly KvKeySummary[], selectedKey: string | null): string[] {
return keys.filter((key) => key.key !== selectedKey && typeof key.lease === "number" && key.lease > 0).map((key) => key.key);
export function removeMissingKvKey(keys: readonly KvKeySummary[], missingKey: string, missingKeyIdentity?: string | null): KvKeySummary[] {
return keys.filter((summary) => !matchesKvKey(summary, missingKey, missingKeyIdentity));
}
export function mergeKvKeyMetadata(keys: readonly KvKeySummary[], key: string, incoming: KvGetResponse): KvKeySummary[] {
if (!incoming.found) return removeMissingKvKey(keys, key);
export function hasPositiveKvLease(lease: string | number | null | undefined): boolean {
if (typeof lease === "number") return Number.isInteger(lease) && lease > 0;
return typeof lease === "string" && /^[1-9]\d*$/.test(lease.trim());
}
export function knownKvLeaseSummaries(keys: readonly KvKeySummary[], selectedKeyIdentity: string | null): KvKeySummary[] {
return keys.filter((key) => (key.keyIdentity ?? key.key) !== selectedKeyIdentity && hasPositiveKvLease(key.lease));
}
export function mergeKvKeyMetadata(keys: readonly KvKeySummary[], key: string, incoming: KvGetResponse, keyIdentity?: string | null): KvKeySummary[] {
if (!incoming.found) return removeMissingKvKey(keys, key, keyIdentity);
if (!incoming.metadata) return [...keys];
return keys.map((item) => (item.key === key ? { ...item, ...incoming.metadata, key: item.key, valueSize: item.valueSize } : item));
return keys.map((item) => (matchesKvKey(item, key, keyIdentity) ? { ...item, ...incoming.metadata, key: item.key, valueSize: item.valueSize } : item));
}
export function nextKvLeaseRefreshDelay(currentDelayMs: number, failed: boolean, baseDelayMs = 2000, maxDelayMs = 30000): number {
@@ -0,0 +1,40 @@
export type KvMutationErrorKind = "keyAlreadyExists" | "conflict" | "request";
export interface KvMutationError {
kind: KvMutationErrorKind;
message: string;
}
export interface KvMutationErrorMessages {
keyAlreadyExists?: string;
conflict?: string;
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error || "");
}
export function sanitizeKvMutationError(error: unknown): string {
const raw = errorText(error).trim();
const withoutStderr = raw.split(/\s*(?:\.\s*)?recent stderr\s*:/i, 1)[0]?.trim() || raw;
return withoutStderr.replace(/^Agent RPC error(?:\s*\([^)]*\))?\s*:\s*/i, "").slice(0, 600);
}
export function classifyKvMutationError(error: unknown, creating: boolean, messages: KvMutationErrorMessages = {}): KvMutationError {
const sanitized = sanitizeKvMutationError(error);
const normalized = sanitized.toLowerCase();
if (normalized.includes("etcd_key_already_exists") || (creating && normalized.includes("etcd_cas_conflict"))) {
return {
kind: "keyAlreadyExists",
message: messages.keyAlreadyExists || "A Key with this name already exists. Choose another name or edit the existing Key.",
};
}
if (normalized.includes("etcd_cas_conflict")) {
return {
kind: "conflict",
message: messages.conflict || "The Key changed after it was loaded. Refresh it and try again.",
};
}
return { kind: "request", message: sanitized };
}
@@ -1,6 +1,6 @@
import type { KvKeySummary } from "@/lib/backend/api";
export function refreshedKvSelectionKey(previousKey: string | null | undefined, refreshedKeys: readonly KvKeySummary[]): string | null {
if (!previousKey) return null;
return refreshedKeys.some((item) => item.key === previousKey) ? previousKey : null;
export function refreshedKvSelectionSummary(previousIdentity: string | null | undefined, refreshedKeys: readonly KvKeySummary[]): KvKeySummary | null {
if (!previousIdentity) return null;
return refreshedKeys.find((item) => (item.keyIdentity ?? item.key) === previousIdentity) ?? null;
}
+13
View File
@@ -1,3 +1,5 @@
import type { KvInt64 } from "@/lib/backend/api";
export type ParsedOptionalTtl = { ok: true; ttl: number | null } | { ok: false };
export function parseOptionalTtl(value: string | number | null | undefined): ParsedOptionalTtl {
@@ -11,3 +13,14 @@ export function parseOptionalTtl(value: string | number | null | undefined): Par
if (!Number.isSafeInteger(ttl)) return { ok: false };
return { ok: true, ttl };
}
export function parseKvLeaseId(value: string | number | null | undefined): KvInt64 | null {
const normalized = String(value ?? "").trim();
if (!/^[1-9]\d*$/.test(normalized)) return null;
try {
if (BigInt(normalized) > 9_223_372_036_854_775_807n) return null;
} catch {
return null;
}
return normalized;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function prettyPrintJsonText(text: string): PrettyPrintJsonResult {
}
}
function valueLabel(value: number | null | undefined): string {
function valueLabel(value: string | number | null | undefined): string {
return value == null ? "-" : String(value);
}
+102
View File
@@ -0,0 +1,102 @@
import { jsonLanguage } from "@codemirror/lang-json";
import { yamlLanguage } from "@codemirror/lang-yaml";
import { xmlLanguage } from "@codemirror/lang-xml";
export type KvValueFormat = "text" | "base64" | "json" | "yaml" | "xml" | "sql" | "properties" | "shell" | "dockerfile" | "nginx" | "kubernetes";
const DOCKERFILE_INSTRUCTIONS = new Set(["ADD", "ARG", "CMD", "COPY", "ENTRYPOINT", "ENV", "EXPOSE", "FROM", "HEALTHCHECK", "LABEL", "MAINTAINER", "ONBUILD", "RUN", "SHELL", "STOPSIGNAL", "USER", "VOLUME", "WORKDIR"]);
function syntaxError(language: { parser: { parse: (text: string) => { cursor: () => { type: { isError: boolean }; next: () => boolean } } } }, text: string): boolean {
const cursor = language.parser.parse(text).cursor();
do {
if (cursor.type.isError) return true;
} while (cursor.next());
return false;
}
export function detectKvValueFormat(text: string, encoding: "utf8" | "base64" = "utf8"): KvValueFormat {
if (encoding === "base64") return "base64";
const trimmed = text.trim();
if (!trimmed) return "text";
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && !syntaxError(jsonLanguage, text)) return "json";
if (trimmed.startsWith("<") && !syntaxError(xmlLanguage, text)) return "xml";
if (/^#!\s*\/(?:usr\/bin\/env\s+)?(?:ba|z|k)?sh\b/m.test(text)) return "shell";
if (/^(?:#\s*syntax=\S+\s*)?(?:FROM|ARG)\s+\S+/im.test(text)) return "dockerfile";
if (/^\s*(?:events|http|server|upstream|location)\b[^{;]*\{/m.test(text)) return "nginx";
if (/^\s*apiVersion\s*:\s*\S+/m.test(text) && /^\s*kind\s*:\s*\S+/m.test(text) && !syntaxError(yamlLanguage, text)) return "kubernetes";
if (/^\s*(?:SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH|GRANT|REVOKE)\b/im.test(text)) return "sql";
if (/^(?:[#!].*\n)*\s*[A-Za-z0-9_.-]+\s*=\s*\S/m.test(text)) return "properties";
if (/^(---\s*$|[A-Za-z0-9_.-]+\s*:)/m.test(text) && !syntaxError(yamlLanguage, text)) return "yaml";
return "text";
}
export function validateKvValue(text: string, format: KvValueFormat): string | null {
if (format === "json") {
try {
JSON.parse(text);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
if ((format === "yaml" || format === "kubernetes") && syntaxError(yamlLanguage, text)) return "Invalid YAML syntax";
if (format === "xml" && syntaxError(xmlLanguage, text)) return "Invalid XML syntax";
if (format === "base64") {
const normalized = text.replace(/\s+/g, "");
if (normalized && (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0)) return "Invalid Base64 data";
}
if (format === "kubernetes" && text.trim()) {
const documents = text
.split(/^---\s*$/m)
.map((document) => document.trim())
.filter(Boolean);
if (documents.some((document) => !/^\s*apiVersion\s*:\s*\S+/m.test(document) || !/^\s*kind\s*:\s*\S+/m.test(document))) {
return "Each Kubernetes document must contain apiVersion and kind";
}
}
if (format === "properties") {
let continuing = false;
const invalidLine = text.split(/\r?\n/).find((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) return false;
if (continuing) {
continuing = trimmed.endsWith("\\");
return false;
}
continuing = trimmed.endsWith("\\");
return !/[=:]/.test(trimmed) && !continuing;
});
if (invalidLine) return `Invalid Properties entry: ${invalidLine}`;
if (continuing) return "Invalid Properties entry: unfinished continuation";
}
if (format === "dockerfile") {
let continuing = false;
const invalidInstruction = text.split(/\r?\n/).find((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return false;
if (continuing) {
continuing = trimmed.endsWith("\\");
return false;
}
continuing = trimmed.endsWith("\\");
return !DOCKERFILE_INSTRUCTIONS.has(trimmed.split(/\s+/, 1)[0].toUpperCase());
});
if (invalidInstruction) return `Unknown Dockerfile instruction: ${invalidInstruction.split(/\s+/, 1)[0]}`;
if (continuing) return "Invalid Dockerfile: unfinished line continuation";
}
if (format === "nginx") {
let depth = 0;
for (const character of text.replace(/#[^\r\n]*/g, "")) {
if (character === "{") depth++;
if (character === "}") depth--;
if (depth < 0) return "Invalid Nginx configuration: unexpected closing brace";
}
if (depth !== 0) return "Invalid Nginx configuration: unbalanced braces";
}
return null;
}
export function formatKvValue(text: string, format: KvValueFormat): string {
if (format === "json") return JSON.stringify(JSON.parse(text), null, 2);
return text;
}
@@ -30,6 +30,10 @@ export type ActiveTabSidebarTarget =
type: "etcd-root";
connectionId: string;
}
| {
type: "etcd-dashboard";
connectionId: string;
}
| {
type: "zookeeper-root";
connectionId: string;
@@ -112,6 +116,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
return { type: "etcd-root", connectionId: tab.connectionId };
}
if (tab.mode === "etcd-dashboard") {
return { type: "etcd-dashboard", connectionId: tab.connectionId };
}
if (tab.mode === "zookeeper") {
return { type: "zookeeper-root", connectionId: tab.connectionId };
}
@@ -179,6 +187,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "etcd-root" && node.connectionId === target.connectionId;
}
if (target.type === "etcd-dashboard") {
return node.type === "etcd-dashboard" && node.connectionId === target.connectionId;
}
if (target.type === "zookeeper-root") {
return node.type === "zookeeper-root" && node.connectionId === target.connectionId;
}
@@ -14,6 +14,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
"object-browser",
"redis-db",
"mq-tenant",
"etcd-root",
"etcd-dashboard",
"zookeeper-root",
"mongo-gridfs",
"mongo-bucket",
@@ -8,7 +8,7 @@ export type SidebarActivation = "single" | "double";
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "etcd-root", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "etcd-root", "etcd-dashboard", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser"]);
const sourceNodeTypes = new Set<TreeNodeType>(["materialized_view", "procedure", "function", "trigger", "sequence", "package", "package-body", "type", "type-body"]);
const savedSqlNodeTypes = new Set<TreeNodeType>(["saved-sql-file"]);
+3 -1
View File
@@ -1,5 +1,5 @@
import type { Component } from "vue";
import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Key, Link, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Gauge, Key, Link, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
import type { ColumnInfo, TreeNode } from "@/types/database";
export type TreeNodeIconInfo = {
@@ -30,6 +30,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
case "nacos-namespace":
case "etcd-root":
return { icon: FolderOpen, 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 "table":
@@ -102,6 +102,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@keys`;
}
if (tab.mode === "etcd-dashboard") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@dashboard`;
}
if (tab.mode === "zookeeper") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@keys`;
@@ -331,6 +335,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "vector") return t("tabs.vector");
if (tab.mode === "redis") return t("tabs.redis");
if (tab.mode === "etcd") return t("tabs.etcd");
if (tab.mode === "etcd-dashboard") return t("tabs.etcdDashboard");
if (tab.mode === "zookeeper") return t("tabs.zookeeper");
if (tab.mode === "nacos") return "Nacos";
if (tab.mode === "objects") return t("tabs.objects");
@@ -2870,6 +2870,15 @@ export const useConnectionStore = defineStore("connection", () => {
isExpanded: false,
children: [],
},
{
id: `${connectionId}:etcd-dashboard`,
label: "Dashboard",
type: "etcd-dashboard" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
],
node,
),
+2 -1
View File
@@ -721,6 +721,7 @@ export type TreeNodeType =
| "mq-tenant"
| "nacos-namespace"
| "etcd-root"
| "etcd-dashboard"
| "zookeeper-root"
| "mongo-db"
| "mongo-gridfs"
@@ -898,7 +899,7 @@ export interface QueryTab {
explainClientSessionId?: string;
/** Invalidates tab-scoped completion metadata after session context changes. */
completionContextVersion?: number;
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "nacos-dashboard" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard";
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "etcd-dashboard" | "zookeeper" | "mq" | "nacos" | "nacos-dashboard" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard";
mqTenant?: string;
mqInitialTab?: "topics";
nacosNamespace?: string;