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":
|
||||
|
||||
Reference in new issue
Block a user