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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3119,6 +3119,8 @@ export default withEnglishFallback({
|
||||
saved: "Znodeを保存しました",
|
||||
deleted: "Znodeを削除しました",
|
||||
base64Readonly: "Base64値はこのバージョンでは読み取り専用です。",
|
||||
utf8PreviewLossy: "無効な UTF-8 バイトは置換文字(�)で表示されます。元の Base64 値は変更されません。",
|
||||
utf8PreviewUnavailable: "Base64 値が無効なため UTF-8 プレビューを生成できません。元の値を表示しています。",
|
||||
createMode: "作成モード",
|
||||
createModePersistent: "永続",
|
||||
createModeEphemeral: "エフェメラル",
|
||||
|
||||
@@ -3847,6 +3847,8 @@ export default withEnglishFallback({
|
||||
saved: "Znode를 저장했습니다",
|
||||
deleted: "Znode를 삭제했습니다",
|
||||
base64Readonly: "Base64 값은 이 버전에서 읽기 전용입니다.",
|
||||
utf8PreviewLossy: "잘못된 UTF-8 바이트는 대체 문자(�)로 표시됩니다. 원본 Base64 값은 변경되지 않습니다.",
|
||||
utf8PreviewUnavailable: "Base64 값이 잘못되어 UTF-8 미리 보기를 생성할 수 없습니다. 원본 값을 표시합니다.",
|
||||
createMode: "생성 모드",
|
||||
createModePersistent: "영구",
|
||||
createModeEphemeral: "임시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4359,6 +4359,8 @@ export default withEnglishFallback({
|
||||
saved: "Znode 已保存",
|
||||
deleted: "Znode 已删除",
|
||||
base64Readonly: "Base64 值当前版本只读。",
|
||||
utf8PreviewLossy: "UTF-8 预览中的无效字节已显示为替换字符(�),原始 Base64 值未改变。",
|
||||
utf8PreviewUnavailable: "Base64 值无效,无法生成 UTF-8 预览,当前显示原始值。",
|
||||
createMode: "创建模式",
|
||||
createModePersistent: "持久节点",
|
||||
createModeEphemeral: "临时节点",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) };
|
||||
|
||||
Reference in new issue
Block a user