feat(zookeeper): add UTF-8 value preview

Closes #6897
This commit is contained in:
t8y2 committed 2026-08-22 07:21:26 +00:00
1 parent b2cf487734
commit fe269a28db
14 files changed
+209 -6

No files matched your search

@@ -24,7 +24,7 @@ import { decideKvMetadataRefresh, hasPositiveKvLease, knownKvLeaseSummaries, mer
import { classifyKvMutationError, type KvMutationErrorKind } from "@/lib/kv/kvMutationError";
import { refreshedKvSelectionSummary } from "@/lib/kv/kvRefreshSelection";
import { parseKvLeaseId, parseOptionalTtl } from "@/lib/kv/kvTtl";
import { formatZooKeeperMetadataRows, formatZooKeeperSummaryBadges, prettyPrintJsonText } from "@/lib/kv/kvValueDisplay";
import { decodeBase64Utf8Preview, formatZooKeeperMetadataRows, formatZooKeeperSummaryBadges, prettyPrintJsonText } from "@/lib/kv/kvValueDisplay";
import { formatTtl } from "@/lib/common/ttlFormat";
import {
createLazyKvKeyTreeState,
@@ -118,6 +118,8 @@ interface KvKeyBrowserLabels {
copy?: string;
copied?: string;
copyFailed?: string;
utf8PreviewLossy?: string;
utf8PreviewUnavailable?: string;
valueTooLarge?: string;
deletePrefix?: string;
selectAll?: string;
@@ -195,6 +197,7 @@ const props = withDefaults(
safeWrite?: boolean;
maxValueBytes?: number;
allowBinaryEdit?: boolean;
enableBase64Utf8Preview?: boolean;
readOnly?: boolean;
exportFormat?: string;
exportFileExtension?: string;
@@ -221,6 +224,7 @@ const props = withDefaults(
safeWrite: false,
maxValueBytes: 0,
allowBinaryEdit: false,
enableBase64Utf8Preview: false,
readOnly: false,
exportFormat: "dbx-kv-bundle",
exportFileExtension: ".dbx-kv.json",
@@ -290,6 +294,7 @@ const showHistoryDiff = ref(false);
const restoring = ref(false);
const selectedCreateMode = ref<KvCreateMode>("persistent");
const selectedPrettyValue = ref<string | null>(null);
const selectedBase64ViewMode = ref<"utf8" | "base64">("utf8");
const selectedValueCopied = ref(false);
const lazyTreeState = reactive(createLazyKvKeyTreeState(lazyKvRootPath(props.lazyPathStyle), props.lazyPathStyle));
const multiSelectedKeys = ref<Map<string, KvMultiSelection>>(new Map());
@@ -385,8 +390,28 @@ const selectedTextValue = computed(() => {
if (!value) return "";
return value.encoding === "utf8" ? value.data : value.data;
});
const displayedSelectedTextValue = computed(() => selectedPrettyValue.value ?? selectedTextValue.value);
const selectedValueIsBase64 = computed(() => selectedValue.value?.value?.encoding === "base64");
const selectedBase64Utf8Preview = computed(() => {
if (!props.enableBase64Utf8Preview || !selectedValueIsBase64.value) return null;
return decodeBase64Utf8Preview(selectedTextValue.value);
});
const showSelectedBase64Utf8Controls = computed(() => props.enableBase64Utf8Preview && selectedValueIsBase64.value);
const selectedBase64DisplayMode = computed<"utf8" | "base64">(() => (selectedBase64ViewMode.value === "utf8" && selectedBase64Utf8Preview.value?.ok ? "utf8" : "base64"));
const selectedValueUsesUtf8Preview = computed(() => showSelectedBase64Utf8Controls.value && selectedBase64DisplayMode.value === "utf8");
const selectedBase64Utf8PreviewIsLossy = computed(() => {
const preview = selectedBase64Utf8Preview.value;
return preview?.ok === true && preview.lossy;
});
const selectedBase64Utf8PreviewUnavailable = computed(() => selectedBase64Utf8Preview.value?.ok === false);
const displayedSelectedTextValue = computed(() => {
if (selectedPrettyValue.value != null) return selectedPrettyValue.value;
const preview = selectedBase64Utf8Preview.value;
return selectedValueUsesUtf8Preview.value && preview?.ok ? preview.value : selectedTextValue.value;
});
const selectedValueClipboardText = computed(() => {
const preview = selectedBase64Utf8Preview.value;
return selectedValueUsesUtf8Preview.value && preview?.ok ? preview.value : selectedTextValue.value;
});
const selectedKeyBytes = computed(() => selectedValue.value?.keyBytes ?? selectedRouteKeyBytes.value ?? null);
const selectedKeyLocked = computed(() => Boolean(String(selectedMetadata.value?.session ?? "").trim()));
const isWatchingSelectedKey = computed(() => Boolean(selectedKey.value && props.watchActiveKey === selectedKey.value));
@@ -856,6 +881,7 @@ async function loadSelectedKey(input: string | KvKeyRoute) {
selectedRouteKeyBytes.value = route.keyBytes ?? null;
selectedValue.value = null;
selectedPrettyValue.value = null;
selectedBase64ViewMode.value = "utf8";
detailLoading.value = true;
detailError.value = "";
try {
@@ -891,6 +917,7 @@ function clearSelectedKey() {
selectedRouteKeyBytes.value = null;
selectedValue.value = null;
selectedPrettyValue.value = null;
selectedBase64ViewMode.value = "utf8";
detailLoading.value = false;
}
@@ -1364,7 +1391,7 @@ async function copyText(value: string | number | null | undefined) {
async function copySelectedValue() {
if (!selectedValue.value?.found) return;
try {
await copyToClipboard(selectedTextValue.value);
await copyToClipboard(selectedValueClipboardText.value);
selectedValueCopied.value = true;
toast(props.labels.copied || "Copied", 1500);
if (selectedValueCopyTimer) clearTimeout(selectedValueCopyTimer);
@@ -2025,7 +2052,23 @@ defineExpose({
</div>
</div>
<div class="min-h-0">
<div v-if="metadataStyle === 'zookeeper'" class="mb-2 text-xs font-medium text-muted-foreground">{{ labels.value || "Value" }}</div>
<div v-if="metadataStyle === 'zookeeper' || showSelectedBase64Utf8Controls" class="mb-2 flex min-h-8 items-center justify-between gap-3">
<div v-if="metadataStyle === 'zookeeper'" class="text-xs font-medium text-muted-foreground">{{ labels.value || "Value" }}</div>
<div v-if="showSelectedBase64Utf8Controls" class="ml-auto inline-flex rounded-md border bg-muted/20 p-0.5" role="group" aria-label="Value encoding">
<Button
data-testid="kv-base64-utf8-view"
size="sm"
:variant="selectedBase64DisplayMode === 'utf8' ? 'secondary' : 'ghost'"
class="h-7 rounded-sm px-2.5 text-xs"
:aria-pressed="selectedBase64DisplayMode === 'utf8'"
:disabled="selectedBase64Utf8PreviewUnavailable"
@click="selectedBase64ViewMode = 'utf8'"
>
UTF-8
</Button>
<Button data-testid="kv-base64-raw-view" size="sm" :variant="selectedBase64DisplayMode === 'base64' ? 'secondary' : 'ghost'" class="h-7 rounded-sm px-2.5 text-xs" :aria-pressed="selectedBase64DisplayMode === 'base64'" @click="selectedBase64ViewMode = 'base64'"> Base64 </Button>
</div>
</div>
<div class="relative">
<Button
size="icon"
@@ -2044,6 +2087,12 @@ defineExpose({
:class="settingsStore.editorSettings.wordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
><template v-for="(segment, index) in selectedValueHighlightSegments" :key="index"><mark v-if="segment.matched" class="rounded-sm bg-amber-300/80 px-0.5 text-foreground dark:bg-amber-500/40">{{ segment.text }}</mark><span v-else>{{ segment.text }}</span></template></pre>
</div>
<div v-if="selectedValueUsesUtf8Preview && selectedBase64Utf8PreviewIsLossy" class="mt-2 text-xs text-amber-700 dark:text-amber-300">
{{ labels.utf8PreviewLossy || "Invalid UTF-8 bytes are shown as replacement characters (�). The original Base64 value is unchanged." }}
</div>
<div v-else-if="showSelectedBase64Utf8Controls && selectedBase64Utf8PreviewUnavailable" class="mt-2 text-xs text-destructive">
{{ labels.utf8PreviewUnavailable || "The Base64 value is invalid, so the original value is shown." }}
</div>
<div v-if="selectedValueCanPrettyJson" class="mt-2 flex justify-end">
<Button size="sm" variant="outline" class="h-8" @click="prettifySelectedJson">
{{ labels.prettyJson || "Pretty" }}
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
describe("KvKeyBrowser Base64 UTF-8 preview", () => {
it("keeps the preview opt-in for backend-specific wrappers", () => {
expect(browserSource).toContain("enableBase64Utf8Preview?: boolean");
expect(browserSource).toContain("enableBase64Utf8Preview: false");
});
it("copies the selected derived view while preserving the raw value path", () => {
expect(browserSource).toContain("selectedValueClipboardText");
expect(browserSource).toContain("copyToClipboard(selectedValueClipboardText.value)");
expect(browserSource.match(/selectedBase64ViewMode\.value = "utf8"/g)).toHaveLength(2);
expect(browserSource).toContain("editValue.value = selectedTextValue.value");
expect(browserSource).toContain("value: selectedValue.value.value");
});
it("offers explicit UTF-8 and Base64 view controls with error feedback", () => {
expect(browserSource).toContain('data-testid="kv-base64-utf8-view"');
expect(browserSource).toContain('data-testid="kv-base64-raw-view"');
expect(browserSource).toContain("labels.utf8PreviewLossy");
expect(browserSource).toContain("labels.utf8PreviewUnavailable");
});
});
@@ -43,6 +43,8 @@ const labels = computed(() => ({
saved: t("zookeeper.saved"),
deleted: t("zookeeper.deleted"),
base64Readonly: t("zookeeper.base64Readonly"),
utf8PreviewLossy: t("zookeeper.utf8PreviewLossy"),
utf8PreviewUnavailable: t("zookeeper.utf8PreviewUnavailable"),
createMode: t("zookeeper.createMode"),
add: t("zookeeper.add"),
value: t("zookeeper.value"),
@@ -67,5 +69,5 @@ defineExpose({ focusSearch, refresh });
</script>
<template>
<KvKeyBrowser ref="browserRef" :connection-id="props.connectionId" :api="zookeeperApi" :labels="labels" supports-create-modes enable-node-actions metadata-style="zookeeper" lazy-hierarchy :create-mode-options="createModeOptions" />
<KvKeyBrowser ref="browserRef" :connection-id="props.connectionId" :api="zookeeperApi" :labels="labels" supports-create-modes enable-node-actions enable-base64-utf8-preview metadata-style="zookeeper" lazy-hierarchy :create-mode-options="createModeOptions" />
</template>
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, type App } from "vue";
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => ({
zookeeperListPrefix: vi.fn(),
zookeeperGet: vi.fn(),
zookeeperPut: vi.fn(),
zookeeperDelete: vi.fn(),
}));
vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({
default: defineComponent({
name: "KvKeyBrowserStub",
props: {
enableBase64Utf8Preview: { type: Boolean, default: false },
labels: { type: Object, required: true },
},
setup(props) {
return () =>
h("div", {
id: "kv-browser-stub",
"data-base64-utf8-preview": String(props.enableBase64Utf8Preview),
"data-lossy-label": (props.labels as Record<string, string>).utf8PreviewLossy,
});
},
}),
}));
import ZooKeeperKeyBrowser from "@/components/zookeeper/ZooKeeperKeyBrowser.vue";
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
afterEach(() => {
app?.unmount();
root?.remove();
app = null;
root = null;
});
describe("ZooKeeperKeyBrowser", () => {
it("enables the Base64 UTF-8 preview with ZooKeeper labels", () => {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(ZooKeeperKeyBrowser, { connectionId: "zookeeper-1" });
app.mount(root);
const browser = root.querySelector("#kv-browser-stub");
expect(browser?.getAttribute("data-base64-utf8-preview")).toBe("true");
expect(browser?.getAttribute("data-lossy-label")).toBe("zookeeper.utf8PreviewLossy");
});
});
+2
View File
@@ -4372,6 +4372,8 @@ export default {
saved: "Znode saved",
deleted: "Znode deleted",
base64Readonly: "Base64 values are read-only in this version.",
utf8PreviewLossy: "Invalid UTF-8 bytes are shown as replacement characters (�). The original Base64 value is unchanged.",
utf8PreviewUnavailable: "The Base64 value is invalid, so the original value is shown.",
createMode: "Create Mode",
createModePersistent: "Persistent",
createModeEphemeral: "Ephemeral",
+2
View File
@@ -4195,6 +4195,8 @@ export default withEnglishFallback({
saved: "Znode guardado",
deleted: "Znode eliminado",
base64Readonly: "Los valores Base64 son de solo lectura en esta versión.",
utf8PreviewLossy: "Los bytes UTF-8 no válidos se muestran como caracteres de sustitución (�). El valor Base64 original no cambia.",
utf8PreviewUnavailable: "El valor Base64 no es válido, por lo que se muestra el valor original.",
createMode: "Modo de creación",
createModePersistent: "Persistente",
createModeEphemeral: "Efímero",
+2
View File
@@ -4193,6 +4193,8 @@ export default withEnglishFallback({
saved: "Znode salvato",
deleted: "Znode eliminato",
base64Readonly: "I valori Base64 sono in sola lettura in questa versione.",
utf8PreviewLossy: "I byte UTF-8 non validi vengono mostrati come caratteri sostitutivi (�). Il valore Base64 originale non viene modificato.",
utf8PreviewUnavailable: "Il valore Base64 non è valido, quindi viene mostrato il valore originale.",
createMode: "Modalita Creazione",
createModePersistent: "Persistente",
createModeEphemeral: "Effimero",
+2
View File
@@ -3119,6 +3119,8 @@ export default withEnglishFallback({
saved: "Znodeを保存しました",
deleted: "Znodeを削除しました",
base64Readonly: "Base64値はこのバージョンでは読み取り専用です。",
utf8PreviewLossy: "無効な UTF-8 バイトは置換文字(�)で表示されます。元の Base64 値は変更されません。",
utf8PreviewUnavailable: "Base64 値が無効なため UTF-8 プレビューを生成できません。元の値を表示しています。",
createMode: "作成モード",
createModePersistent: "永続",
createModeEphemeral: "エフェメラル",
+2
View File
@@ -3847,6 +3847,8 @@ export default withEnglishFallback({
saved: "Znode를 저장했습니다",
deleted: "Znode를 삭제했습니다",
base64Readonly: "Base64 값은 이 버전에서 읽기 전용입니다.",
utf8PreviewLossy: "잘못된 UTF-8 바이트는 대체 문자(�)로 표시됩니다. 원본 Base64 값은 변경되지 않습니다.",
utf8PreviewUnavailable: "Base64 값이 잘못되어 UTF-8 미리 보기를 생성할 수 없습니다. 원본 값을 표시합니다.",
createMode: "생성 모드",
createModePersistent: "영구",
createModeEphemeral: "임시",
+2
View File
@@ -4195,6 +4195,8 @@ export default withEnglishFallback({
saved: "Znode salvo",
deleted: "Znode excluído",
base64Readonly: "Valores Base64 são somente leitura nesta versão.",
utf8PreviewLossy: "Bytes UTF-8 inválidos são exibidos como caracteres de substituição (�). O valor Base64 original não é alterado.",
utf8PreviewUnavailable: "O valor Base64 é inválido, portanto o valor original é exibido.",
createMode: "Modo de Criação",
createModePersistent: "Persistente",
createModeEphemeral: "Efêmero",
+2
View File
@@ -4359,6 +4359,8 @@ export default withEnglishFallback({
saved: "Znode 已保存",
deleted: "Znode 已删除",
base64Readonly: "Base64 值当前版本只读。",
utf8PreviewLossy: "UTF-8 预览中的无效字节已显示为替换字符(�),原始 Base64 值未改变。",
utf8PreviewUnavailable: "Base64 值无效,无法生成 UTF-8 预览,当前显示原始值。",
createMode: "创建模式",
createModePersistent: "持久节点",
createModeEphemeral: "临时节点",
+2
View File
@@ -6938,6 +6938,8 @@ export default withEnglishFallback({
saved: "Znode 已儲存",
deleted: "Znode 已刪除",
base64Readonly: "Base64 值在此版本中為唯讀。",
utf8PreviewLossy: "UTF-8 預覽中的無效位元組已顯示為取代字元(�),原始 Base64 值未變更。",
utf8PreviewUnavailable: "Base64 值無效,無法產生 UTF-8 預覽,目前顯示原始值。",
createMode: "建立模式",
createModePersistent: "持久",
createModeEphemeral: "臨時",
+24 -1
View File
@@ -1,8 +1,31 @@
import { describe, expect, it } from "vitest";
import { formatZooKeeperMetadataRows, formatZooKeeperSummaryBadges, prettyPrintJsonText } from "@/lib/kv/kvValueDisplay";
import { decodeBase64Utf8Preview, formatZooKeeperMetadataRows, formatZooKeeperSummaryBadges, prettyPrintJsonText } from "@/lib/kv/kvValueDisplay";
import type { KvKeyMetadata } from "@/lib/backend/api";
describe("kv value display helpers", () => {
it("decodes a valid Base64 value as a lossless UTF-8 preview", () => {
expect(decodeBase64Utf8Preview("5L2g5aW9")).toEqual({
ok: true,
value: "你好",
lossy: false,
});
});
it("marks replacement decoding of invalid UTF-8 bytes as lossy", () => {
expect(decodeBase64Utf8Preview("ZoBv")).toEqual({
ok: true,
value: "f�o",
lossy: true,
});
});
it("rejects malformed Base64 without throwing", () => {
expect(decodeBase64Utf8Preview("not base64!")).toEqual({
ok: false,
error: "invalid_base64",
});
});
it("pretty prints valid JSON text", () => {
expect(prettyPrintJsonText('{"name":"张三","roles":["admin","reader"]}')).toEqual({
ok: true,
+29
View File
@@ -8,6 +8,8 @@ export interface PrettyPrintJsonResult {
error?: "invalid_json";
}
export type Base64Utf8PreviewResult = { ok: true; value: string; lossy: boolean } | { ok: false; error: "invalid_base64" };
export interface DisplayItem {
label: string;
value: string;
@@ -20,6 +22,33 @@ export interface ZooKeeperSummaryLabels {
size?: string;
}
const strictBase64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
export function decodeBase64Utf8Preview(value: string): Base64Utf8PreviewResult {
const normalized = value.replace(/\s+/g, "");
if (!strictBase64Pattern.test(normalized)) return { ok: false, error: "invalid_base64" };
try {
const binary = atob(normalized);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
try {
return {
ok: true,
value: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
lossy: false,
};
} catch {
return {
ok: true,
value: new TextDecoder("utf-8").decode(bytes),
lossy: true,
};
}
} catch {
return { ok: false, error: "invalid_base64" };
}
}
export function prettyPrintJsonText(text: string): PrettyPrintJsonResult {
try {
return { ok: true, value: safeJsonFormat(text, 2) };