fix(redis): bound large string previews to avoid crashes

This commit is contained in:
zipg authored and GitHub committed 2026-09-01 13:56:29 +08:00
1 parent 70d7a78c66
commit 55611706ba
14 files changed
+210 -29

No files matched your search

@@ -94,6 +94,18 @@ function stringValue(rawBase64 = "dmFsdWU=", ttl = 60) {
};
}
function largeStringValue() {
return {
...stringValue("cHJldmlldw=="),
data: {
kind: "string" as const,
content: { raw_base64: "cHJldmlldw==", encoding: "utf8" as const },
total_bytes: 45 * 1024 * 1024,
truncated: true,
},
};
}
function listValue(ttl = 60) {
return {
key_display: "key",
@@ -137,6 +149,8 @@ const testI18nMessages = {
ttlHour: "{count}h",
ttlMinute: "{count}m",
ttlSecond: "{count}s",
largeStringPreviewHint: "Only {loaded} of {total} is loaded. Preview is read-only.",
largeStringPreviewActionUnavailable: "Unavailable for large-value previews",
},
},
};
@@ -250,6 +264,20 @@ async function setStringDraft(value: string) {
}
describe("RedisValueViewer expiry saving", () => {
it("renders a large String as a bounded read-only preview", async () => {
mocks.redisGetValue.mockResolvedValueOnce(largeStringValue());
mountViewer(vi.fn());
await settle();
expect(document.querySelector("textarea")).toBeNull();
expect(document.querySelector<HTMLElement>("[data-redis-large-string-preview]")?.textContent).toContain("Only 7 B of 45.0 MB is loaded");
expect(document.querySelector<HTMLButtonElement>("[aria-label='grid.copyValue']")?.disabled).toBe(true);
expect(document.querySelector<HTMLButtonElement>("[aria-label='redis.copyInsertStatement']")?.disabled).toBe(true);
const gzip = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "GZip");
expect(gzip?.disabled).toBe(true);
});
it("opens member UTF-8 editing from blank space without hijacking text double-clicks", async () => {
mocks.redisGetValue.mockResolvedValueOnce(listValue());
@@ -64,6 +64,7 @@ import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/l
import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry";
import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
import { formatBytes } from "@/lib/database/serverMetrics";
const { t, locale } = useI18n();
const { toast } = useToast();
@@ -439,6 +440,7 @@ const stringBlob = computed<RedisBlob | null>(() => {
if (!value) return null;
return value.data.kind === "string" ? value.data.content : null;
});
const isStringValueTruncated = computed(() => data.value?.data.kind === "string" && Boolean(data.value.data.truncated));
const stringValueDetail = computed(() => (stringBlob.value ? formatRedisMemberDetail(stringBlob.value, { allowJsonText: true }) : null));
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value, { allowJsonText: true }));
@@ -569,7 +571,7 @@ const memberCopyText = computed(() => memberDecodedText.value ?? detailTextForFo
const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
const isBinaryStringValue = computed(() => Boolean(stringValueDetail.value?.binary));
const selectedMemberCanEdit = computed(() => selectedMemberContext.value?.canEdit ?? false);
const canEditCurrentStringFormat = computed(() => Boolean(stringValueDetail.value?.editable) && (stringValueView.value === "utf8" || stringValueView.value === "json"));
const canEditCurrentStringFormat = computed(() => !isStringValueTruncated.value && Boolean(stringValueDetail.value?.editable) && (stringValueView.value === "utf8" || stringValueView.value === "json"));
const showStringEditActions = computed(() => canEditCurrentStringFormat.value);
const originalStringEditValue = computed(() => (stringBlob.value ? rawRedisValueText(stringBlob.value) : ""));
const stringJsonRawBaseline = ref("");
@@ -632,15 +634,26 @@ const zsetGridStyle = computed(() => ({
gridTemplateColumns: `60px ${zsetScoreWidth.value}px minmax(0, 1fr) 104px`,
}));
const metadataSizeLabel = computed(() => {
if (data.value?.data.kind === "string" && data.value.data.total_bytes != null) {
return formatBytes(data.value.data.total_bytes);
}
const metadata = props.metadata;
const size = metadata?.size ?? 0;
if (!metadata || size <= 0) return "";
if (metadata.key_type === "string") {
if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
return formatBytes(size);
}
return String(size);
});
const largeStringPreviewHint = computed(() => {
const value = data.value;
const loaded = stringValueDetail.value?.byteCount ?? 0;
if (!value || value.data.kind !== "string" || !value.data.truncated) return "";
if (value.data.total_bytes != null) {
return t("redis.largeStringPreviewHint", { loaded: formatBytes(loaded), total: formatBytes(value.data.total_bytes) });
}
return t("redis.largeStringPreviewHintUnknown", { loaded: formatBytes(loaded) });
});
const streamRows = computed<RedisStreamRow[]>(() => {
if (redisKind.value !== "stream") return [];
return streamEntries.value.map((entry, index) => ({
@@ -1305,6 +1318,7 @@ function setStringValueFormat(format: RedisValueFormat) {
}
function setStringValueCodec(codec: RedisValueCodec) {
if (isStringValueTruncated.value && codec !== "none") return;
stringValueCodec.value = codec;
rememberRedisValueCodec(codec);
}
@@ -1538,6 +1552,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
}
if (loadedValue.data.kind === "string") {
if (loadedValue.data.truncated) stringValueCodec.value = "none";
const detail = formatRedisMemberDetail(loadedValue.data.content, { allowJsonText: true });
stringValueView.value = preferredRedisValueFormat(loadedValue.data.content, readPreferredRedisValueFormat(), { allowJsonText: true });
stringJsonRawBaseline.value = detail.json?.formattedText ?? "";
@@ -1591,7 +1606,7 @@ async function loadMore() {
}
async function saveString() {
if (!data.value || !stringBlob.value || isBinaryStringValue.value || !stringValueChanged.value || savingString.value) return;
if (!data.value || !stringBlob.value || isStringValueTruncated.value || isBinaryStringValue.value || !stringValueChanged.value || savingString.value) return;
let value = editValue.value;
// Compact whenever this draft is/was JSON-edited, even if the user switched tabs before Save.
@@ -1695,6 +1710,10 @@ async function renameKey() {
async function copyValue() {
if (!data.value) return;
if (isStringValueTruncated.value) {
toast(t("redis.largeStringPreviewActionUnavailable"), 3000);
return;
}
// Copy the decoded text while a codec is decoding the string value.
if (stringValueCodec.value !== "none" && stringDecodedText.value != null) {
await copyText(stringDecodedText.value);
@@ -1805,6 +1824,10 @@ function generateInsertStatements(): string | null {
}
async function copyInsertStatement() {
if (isStringValueTruncated.value) {
toast(t("redis.largeStringPreviewActionUnavailable"), 3000);
return;
}
const stmt = generateInsertStatements();
if (!stmt) {
toast(t("redis.copyInsertStatementBinary"), 3000);
@@ -2734,8 +2757,10 @@ defineExpose({ focusSearch });
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyValue')" :aria-label="t('grid.copyValue')" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.copyInsertStatement')" :aria-label="t('redis.copyInsertStatement')" @click="copyInsertStatement"><ClipboardCopy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="isStringValueTruncated" :title="isStringValueTruncated ? t('redis.largeStringPreviewActionUnavailable') : t('grid.copyValue')" :aria-label="t('grid.copyValue')" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="isStringValueTruncated" :title="isStringValueTruncated ? t('redis.largeStringPreviewActionUnavailable') : t('redis.copyInsertStatement')" :aria-label="t('redis.copyInsertStatement')" @click="copyInsertStatement"
><ClipboardCopy class="h-3.5 w-3.5"
/></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.renameKey')" :aria-label="t('redis.renameKey')" @click="openRenameKeyDialog"><Pencil class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
@@ -2774,11 +2799,21 @@ defineExpose({ focusSearch });
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
<span class="shrink-0 text-muted-foreground">{{ t("redis.codecRowLabel") }}</span>
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
<Button v-for="codec in REDIS_VALUE_CODEC_ORDER" :key="codec" variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': stringValueCodec === codec }" @click="setStringValueCodec(codec)">
<Button
v-for="codec in REDIS_VALUE_CODEC_ORDER"
:key="codec"
variant="ghost"
size="sm"
class="h-6 shrink-0 rounded-[5px] px-2 text-xs"
:class="{ 'bg-background shadow-sm': stringValueCodec === codec }"
:disabled="isStringValueTruncated && codec !== 'none'"
:title="isStringValueTruncated && codec !== 'none' ? t('redis.largeStringPreviewActionUnavailable') : undefined"
@click="setStringValueCodec(codec)"
>
{{ redisCodecLabel(codec) }}
</Button>
</div>
<FileArchive v-if="stringGzipBadge && stringValueCodec === 'none'" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" />
<FileArchive v-if="!isStringValueTruncated && stringGzipBadge && stringValueCodec === 'none'" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" />
<span class="flex-1" />
<label v-if="isTextRedisFormat(stringValueView) || activeStructuredStringDetail || isDecompressCodec(stringValueCodec)" class="flex items-center gap-1.5 text-muted-foreground">
<WrapText class="h-3.5 w-3.5" />
@@ -2882,7 +2917,11 @@ defineExpose({ focusSearch });
/>
<pre v-else-if="canHighlightStringSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)" v-html="contentSearchHighlightedHtml" />
<pre v-else class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)">{{ detailTextForFormat(stringValueDetail, stringValueView) }}</pre>
<div v-if="isBinaryStringValue" class="px-4 py-2 border-t text-xs text-muted-foreground shrink-0">
<div v-if="isStringValueTruncated" data-redis-large-string-preview class="flex shrink-0 items-center gap-2 border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
<Eye class="h-3.5 w-3.5 shrink-0" />
<span>{{ largeStringPreviewHint }}</span>
</div>
<div v-else-if="isBinaryStringValue" class="px-4 py-2 border-t text-xs text-muted-foreground shrink-0">
{{ t("redis.binaryStringReadonlyHint") }}
</div>
<div v-if="showStringEditActions" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
+3
View File
@@ -4773,6 +4773,9 @@ export default {
columnSize: "Size",
columnTTL: "TTL",
binaryStringReadonlyHint: "Binary string values are shown as escaped text in read-only mode; editing raw bytes is not supported.",
largeStringPreviewHint: "This value is large. Only the first {loaded} of {total} is loaded, and the preview is read-only.",
largeStringPreviewHintUnknown: "This value is large. Only the first {loaded} is loaded, and the preview is read-only.",
largeStringPreviewActionUnavailable: "Large-value previews cannot be copied, decoded, or used to generate insert statements",
selectedKeys: "Selected keys",
selectAllLoaded: "Select all",
selectAllLoadedTitle: "Select all keys (Ctrl/Cmd+A)",
+3
View File
@@ -4590,6 +4590,9 @@ export default withEnglishFallback({
columnSize: "Tamaño",
columnTTL: "TTL",
binaryStringReadonlyHint: "Los valores de cadena binaria se muestran como texto escapado en modo de solo lectura; la edición de bytes sin procesar no está disponible.",
largeStringPreviewHint: "Este valor es grande. Solo se cargaron los primeros {loaded} de {total}; la vista previa es de solo lectura.",
largeStringPreviewHintUnknown: "Este valor es grande. Solo se cargaron los primeros {loaded}; la vista previa es de solo lectura.",
largeStringPreviewActionUnavailable: "Las vistas previas de valores grandes no se pueden copiar, decodificar ni usar para generar sentencias de inserción",
selectedKeys: "Claves seleccionadas",
selectAllLoaded: "Seleccionar todo",
selectAllLoadedTitle: "Seleccionar todas las claves (Ctrl/Cmd+A)",
+3
View File
@@ -4588,6 +4588,9 @@ export default withEnglishFallback({
columnSize: "Dimensione",
columnTTL: "TTL",
binaryStringReadonlyHint: "I valori delle stringhe binarie sono mostrati come testo con escape in modalità sola lettura; la modifica dei byte grezzi non è supportata.",
largeStringPreviewHint: "Questo valore è grande. Sono stati caricati solo i primi {loaded} di {total}; l'anteprima è di sola lettura.",
largeStringPreviewHintUnknown: "Questo valore è grande. Sono stati caricati solo i primi {loaded}; l'anteprima è di sola lettura.",
largeStringPreviewActionUnavailable: "Le anteprime di valori grandi non possono essere copiate, decodificate o usate per generare istruzioni di inserimento",
selectedKeys: "Chiavi selezionate",
selectAllLoaded: "Seleziona tutto",
selectAllLoadedTitle: "Seleziona tutte le chiavi (Ctrl/Cmd+A)",
+3
View File
@@ -4617,6 +4617,9 @@ export default withEnglishFallback({
columnSize: "サイズ",
columnTTL: "TTL",
binaryStringReadonlyHint: "バイナリ文字列値は読み取り専用モードでエスケープテキストとして表示されます。生バイトの編集はサポートされていません。",
largeStringPreviewHint: "値が大きいため、{total} のうち先頭 {loaded} のみを読み込みました。プレビューは読み取り専用です。",
largeStringPreviewHintUnknown: "値が大きいため、先頭 {loaded} のみを読み込みました。プレビューは読み取り専用です。",
largeStringPreviewActionUnavailable: "大きな値のプレビューは、コピー、デコード、または INSERT 文の生成には使用できません",
selectedKeys: "選択中のキー",
selectAllLoaded: "すべて選択",
selectAllLoadedTitle: "すべてのキーを選択(Ctrl/Cmd+A)",
+3
View File
@@ -4205,6 +4205,9 @@ export default withEnglishFallback({
columnSize: "크기",
columnTTL: "TTL",
binaryStringReadonlyHint: "바이너리 문자열 값은 읽기 전용 모드에서 이스케이프된 텍스트로 표시되며, 원시 바이트 편집은 지원되지 않습니다.",
largeStringPreviewHint: "값이 너무 커서 전체 {total} 중 앞부분 {loaded}만 불러왔습니다. 미리 보기는 읽기 전용입니다.",
largeStringPreviewHintUnknown: "값이 너무 커서 앞부분 {loaded}만 불러왔습니다. 미리 보기는 읽기 전용입니다.",
largeStringPreviewActionUnavailable: "큰 값 미리 보기에서는 복사, 디코딩 또는 INSERT 문 생성을 사용할 수 없습니다",
selectedKeys: "선택한 키",
selectAllLoaded: "전체 선택",
selectAllLoadedTitle: "모든 키 선택 (Ctrl/Cmd+A)",
+3
View File
@@ -4590,6 +4590,9 @@ export default withEnglishFallback({
columnSize: "Tamanho",
columnTTL: "TTL",
binaryStringReadonlyHint: "Valores de string binária são exibidos como texto escapado no modo somente leitura; a edição de bytes brutos não é suportada.",
largeStringPreviewHint: "Este valor é grande. Apenas os primeiros {loaded} de {total} foram carregados; a visualização é somente leitura.",
largeStringPreviewHintUnknown: "Este valor é grande. Apenas os primeiros {loaded} foram carregados; a visualização é somente leitura.",
largeStringPreviewActionUnavailable: "Visualizações de valores grandes não podem ser copiadas, decodificadas ou usadas para gerar instruções de inserção",
selectedKeys: "Chaves selecionadas",
selectAllLoaded: "Selecionar tudo",
selectAllLoadedTitle: "Selecionar todas as chaves (Ctrl/Cmd+A)",
+3
View File
@@ -4757,6 +4757,9 @@ export default withEnglishFallback({
columnSize: "大小",
columnTTL: "TTL",
binaryStringReadonlyHint: "二进制字符串按转义文本只读展示;当前不支持直接编辑原始字节值。",
largeStringPreviewHint: "值过大,仅加载前 {loaded}(共 {total})。预览为只读。",
largeStringPreviewHintUnknown: "值过大,仅加载前 {loaded}。预览为只读。",
largeStringPreviewActionUnavailable: "大值预览不支持复制、解码或生成插入语句",
selectedKeys: "已选择的 key",
selectAllLoaded: "全选",
selectAllLoadedTitle: "全选 key(Ctrl/Cmd+A)",
+3
View File
@@ -3913,6 +3913,9 @@ export default withEnglishFallback({
columnSize: "大小",
columnTTL: "TTL",
binaryStringReadonlyHint: "二進位字串值會以逸出文字唯讀顯示;不支援編輯原始位元組。",
largeStringPreviewHint: "值過大,僅載入前 {loaded}(共 {total})。預覽為唯讀。",
largeStringPreviewHintUnknown: "值過大,僅載入前 {loaded}。預覽為唯讀。",
largeStringPreviewActionUnavailable: "大值預覽不支援複製、解碼或產生插入語句",
selectedKeys: "已選擇的 key",
selectAllLoaded: "全選",
selectAllLoadedTitle: "全選 key(Ctrl/Cmd+A)",
@@ -180,6 +180,23 @@ describe("redisValuePresentation", () => {
expect(redisValueSize(value)).toBe(1);
});
it("uses the original byte count for a truncated Redis String preview", () => {
const value = {
key_display: "bf:ali_health_monitor",
key_raw: "YmY6YWxpX2hlYWx0aF9tb25pdG9y",
ttl: -1,
redis_type: "string",
data: {
kind: "string" as const,
content: { raw_base64: "cHJldmlldw==", encoding: "utf8" as const },
total_bytes: 45 * 1024 * 1024,
truncated: true,
},
};
expect(redisValueSize(value)).toBe(45 * 1024 * 1024);
});
it("labels raw text views by encoding instead of generic raw text", () => {
expect(formatRedisMemberDetail("plain-text").rawLabel).toBe("ASCII");
expect(
+1 -1
View File
@@ -2542,7 +2542,7 @@ export interface RedisStreamPendingPage {
}
export type RedisValueData =
| { kind: "string"; content: RedisBlob }
| { kind: "string"; content: RedisBlob; total_bytes?: number; truncated?: boolean }
| { kind: "json"; value: string }
| {
kind: "list";
@@ -446,7 +446,7 @@ export function redisValueCollectionScanCursor(value: RedisValue): number | unde
export function redisValueSize(value: RedisValue): number {
switch (value.data.kind) {
case "string":
return decodeRedisBlob(value.data.content).byteLength;
return value.data.total_bytes ?? decodeRedisBlob(value.data.content).byteLength;
case "json":
return new TextEncoder().encode(redisJsonValueText(value.data)).byteLength;
case "list":