fix(etcd): preserve raw key identity in browser actions
This commit is contained in:
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":
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user