feat(redis): add decompressed view for compressed values
This commit is contained in:
parent
06838c422b
commit
ec44e09ec8
|
|
@ -4,7 +4,7 @@ import type { CalendarDateTime } from "@internationalized/date";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
|
||||
import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue";
|
||||
import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, FileArchive } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -29,9 +29,11 @@ import { computeDisplayTtl, computeTtlCountdownTick, computeTtlCountdownValue, c
|
|||
import {
|
||||
canRenderRedisValueFormat,
|
||||
canEditRedisMemberDetail,
|
||||
decodeRedisBlob,
|
||||
formatRedisMemberDetail,
|
||||
getRedisMemberSelectionKey,
|
||||
isRedisBlob,
|
||||
parseRedisJsonDetail,
|
||||
preferredRedisValueFormat,
|
||||
REDIS_VALUE_FORMAT_DISPLAY_ORDER,
|
||||
redisBlobText,
|
||||
|
|
@ -47,6 +49,7 @@ import {
|
|||
type RedisCollectionItem,
|
||||
type RedisValueFormat,
|
||||
} from "@/lib/redis/redisValuePresentation";
|
||||
import { decompressRedisValue, isGzipMagic, type RedisDecompressAlgorithm } from "@/lib/redis/redisCompression";
|
||||
import { canFullHighlightRedisText, findRedisTextMatches, nextRedisSearchMatchIndex, REDIS_VALUE_SEARCH_MATCH_LIMIT, renderRedisTextSearchHtml, redisValueSearchStatus } from "@/lib/redis/redisValueSearch";
|
||||
import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
|
||||
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
|
||||
|
|
@ -164,6 +167,70 @@ const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
|||
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
|
||||
const redisJsonHighlighter = ref<JsonHighlighter>();
|
||||
|
||||
// Decompressed view state. Decompression yields to the event loop so the
|
||||
// loading state paints before the synchronous bounded inflate runs; the result
|
||||
// intentionally lives outside the synchronous format pipeline built by
|
||||
// formatRedisMemberDetail, and the request id guards against stale results when
|
||||
// the user switches values or formats mid-flight.
|
||||
type RedisDecompressedState = { status: "idle" } | { status: "loading" } | { status: "success"; text: string; algorithm: RedisDecompressAlgorithm } | { status: "error"; reason: "corrupt" | "limit" };
|
||||
const decompressedState = ref<RedisDecompressedState>({ status: "idle" });
|
||||
let decompressRequestId = 0;
|
||||
|
||||
async function runDecompress(bytes: Uint8Array, algorithm?: RedisDecompressAlgorithm) {
|
||||
const requestId = ++decompressRequestId;
|
||||
decompressedState.value = { status: "loading" };
|
||||
const result = await decompressRedisValue(bytes, algorithm ? { algorithm } : {});
|
||||
if (requestId !== decompressRequestId) return;
|
||||
if (result.ok) decompressedState.value = { status: "success", text: result.text, algorithm: result.algorithm };
|
||||
else decompressedState.value = { status: "error", reason: result.reason };
|
||||
}
|
||||
|
||||
/** Bytes of whichever decompressed view is active (string value or member), or null when there is nothing to decode. */
|
||||
function currentDecompressTargetBytes(): Uint8Array | null {
|
||||
if (stringValueView.value === "decompressed") {
|
||||
const blob = stringBlob.value;
|
||||
return blob ? decodeRedisBlob(blob) : null;
|
||||
}
|
||||
if (memberValueView.value === "decompressed") {
|
||||
const raw = selectedMemberRaw.value;
|
||||
if (raw == null) return null;
|
||||
// Plain-string members (not blobs) still attempt decompression so the user
|
||||
// gets the non-blocking "not compressed" notice instead of silence.
|
||||
return isRedisBlob(raw) ? decodeRedisBlob(raw) : new TextEncoder().encode(typeof raw === "string" ? raw : formatRedisMemberDetail(raw).rawText);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function refreshDecompressedView(algorithm?: RedisDecompressAlgorithm) {
|
||||
const bytes = currentDecompressTargetBytes();
|
||||
if (bytes) void runDecompress(bytes, algorithm);
|
||||
else decompressedState.value = { status: "idle" };
|
||||
}
|
||||
|
||||
/** Last-resort explicit decode for values that are raw RFC 1951 DEFLATE (never auto-detected). */
|
||||
function retryDecompressAsDeflate() {
|
||||
refreshDecompressedView("deflate");
|
||||
}
|
||||
|
||||
const decompressedJsonDetail = computed(() => {
|
||||
const state = decompressedState.value;
|
||||
return state.status === "success" ? parseRedisJsonDetail(state.text) : null;
|
||||
});
|
||||
|
||||
const decompressedFailureMessage = computed(() => {
|
||||
const state = decompressedState.value;
|
||||
if (state.status !== "error") return "";
|
||||
if (state.reason === "limit") return t("redis.decompressedLimitExceeded");
|
||||
return t("redis.decompressedFailed");
|
||||
});
|
||||
|
||||
/** Format label shows the algorithm that actually succeeded, e.g. "Decompressed (zlib)". */
|
||||
const decompressedLabel = computed(() => {
|
||||
const state = decompressedState.value;
|
||||
const base = t("redis.decompressedView");
|
||||
return state.status === "success" ? `${base} (${state.algorithm})` : base;
|
||||
});
|
||||
|
||||
// Auto-refresh keeps the displayed TTL moving locally and periodically reloads
|
||||
// the complete key detail. The full reload updates changed values as well as
|
||||
// the authoritative TTL without rebuilding the parent key tree.
|
||||
|
|
@ -333,6 +400,42 @@ const stringBlob = computed<RedisBlob | null>(() => {
|
|||
});
|
||||
const stringValueDetail = computed(() => (stringBlob.value ? formatRedisMemberDetail(stringBlob.value, { allowJsonText: true }) : null));
|
||||
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value, { allowJsonText: true }));
|
||||
|
||||
// The Decompressed view depends on the value/format refs above, so these
|
||||
// watchers and computeds live here rather than next to the state declarations.
|
||||
watch([stringValueView, stringBlob], ([view]) => {
|
||||
if (view !== "decompressed") return;
|
||||
refreshDecompressedView();
|
||||
});
|
||||
|
||||
watch([memberValueView, selectedMemberRaw], ([view]) => {
|
||||
if (view !== "decompressed") return;
|
||||
refreshDecompressedView();
|
||||
});
|
||||
|
||||
const stringGzipBadge = computed(() => (stringBlob.value ? isGzipMagic(decodeRedisBlob(stringBlob.value)) : false));
|
||||
const memberGzipBadge = computed(() => (isRedisBlob(selectedMemberRaw.value) ? isGzipMagic(decodeRedisBlob(selectedMemberRaw.value)) : false));
|
||||
|
||||
/** Raw fallback shown while Decompressed fails: keep the original content visible, never an error string in its place. */
|
||||
const decompressedRawFallbackText = computed(() => {
|
||||
if (stringValueView.value === "decompressed" && stringValueDetail.value) {
|
||||
return detailTextForFormat(stringValueDetail.value, stringValueDetail.value.defaultFormat);
|
||||
}
|
||||
if (memberValueView.value === "decompressed" && selectedMemberDetail.value) {
|
||||
return detailTextForFormat(selectedMemberDetail.value, selectedMemberDetail.value.defaultFormat);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
/** Copy targets the decompressed text while the Decompressed view is showing it. */
|
||||
const memberCopyText = computed(() => {
|
||||
if (memberValueView.value === "decompressed") {
|
||||
const state = decompressedState.value;
|
||||
if (state.status === "success") return state.text;
|
||||
}
|
||||
return detailTextForFormat(selectedMemberDetail.value, memberValueView.value);
|
||||
});
|
||||
|
||||
const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
const isBinaryStringValue = computed(() => Boolean(stringValueDetail.value?.binary));
|
||||
const selectedMemberCanEdit = computed(() => selectedMemberContext.value?.canEdit ?? false);
|
||||
|
|
@ -462,10 +565,18 @@ const valueSearchSupported = computed(() => showMemberDetail.value || isStringLi
|
|||
const contentSearchText = computed(() => {
|
||||
if (showMemberDetail.value) {
|
||||
if (isEditingMember.value || isEditingHashJson.value) return memberEditValue.value;
|
||||
if (memberValueView.value === "decompressed") {
|
||||
const state = decompressedState.value;
|
||||
return state.status === "success" ? state.text : "";
|
||||
}
|
||||
return detailTextForFormat(selectedMemberDetail.value, memberValueView.value);
|
||||
}
|
||||
if (redisKind.value === "json") return editValue.value;
|
||||
if (!isStringLikeKind.value || !stringValueDetail.value) return "";
|
||||
if (stringValueView.value === "decompressed") {
|
||||
const state = decompressedState.value;
|
||||
return state.status === "success" ? state.text : "";
|
||||
}
|
||||
if (stringValueView.value === "json" && stringValueDetail.value.json) return editValue.value;
|
||||
if (stringValueView.value === "utf8" && canEditCurrentStringFormat.value) return editValue.value;
|
||||
return detailTextForFormat(stringValueDetail.value, stringValueView.value);
|
||||
|
|
@ -946,7 +1057,8 @@ function setStringValueFormat(format: RedisValueFormat) {
|
|||
// Keep a dirty JSON draft marked as json so save still compact-writes after a tab switch.
|
||||
if (!(stringDraftFormat.value === "json" && isStringDraftDirty("json"))) stringDraftFormat.value = "utf8";
|
||||
}
|
||||
rememberRedisValueFormat(format);
|
||||
// Decompressed is a per-value view, never a persisted preference.
|
||||
if (format !== "decompressed") rememberRedisValueFormat(format);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -968,7 +1080,8 @@ function setMemberValueFormat(format: RedisValueFormat) {
|
|||
memberDraftFormat.value = "utf8";
|
||||
}
|
||||
}
|
||||
rememberRedisValueFormat(format);
|
||||
// Decompressed is a per-value view, never a persisted preference.
|
||||
if (format !== "decompressed") rememberRedisValueFormat(format);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -988,13 +1101,15 @@ function redisFormatLabel(format: RedisValueFormat, rawLabel?: string): string {
|
|||
return t("grid.hexViewerHex");
|
||||
case "base64":
|
||||
return "Base64";
|
||||
case "decompressed":
|
||||
return decompressedLabel.value;
|
||||
default:
|
||||
return rawLabel || t("redis.rawContent");
|
||||
}
|
||||
}
|
||||
|
||||
function isTextRedisFormat(format: RedisValueFormat): boolean {
|
||||
return format === "utf8" || format === "ascii" || format === "binary" || format === "json";
|
||||
return format === "utf8" || format === "ascii" || format === "binary" || format === "json" || format === "decompressed";
|
||||
}
|
||||
|
||||
function highlightRedisJson(json: string): string {
|
||||
|
|
@ -1216,6 +1331,14 @@ function requestDeleteKey() {
|
|||
|
||||
async function copyValue() {
|
||||
if (!data.value) return;
|
||||
// Copy the decompressed text while the Decompressed view is showing it.
|
||||
if (stringValueView.value === "decompressed") {
|
||||
const state = decompressedState.value;
|
||||
if (state.status === "success") {
|
||||
await copyText(state.text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const value = data.value.data.kind === "stream" ? { ...data.value, data: { ...data.value.data, entries: streamEntries.value } } : data.value;
|
||||
const text = redisValueCopyText(value, collectionItems.value);
|
||||
try {
|
||||
|
|
@ -2115,6 +2238,10 @@ defineExpose({ focusSearch });
|
|||
{{ redisFormatLabel(format, stringValueDetail.rawLabel) }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-if="stringGzipBadge" variant="outline" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" @click="setStringValueFormat('decompressed')">
|
||||
<FileArchive class="h-3.5 w-3.5 mr-1" />
|
||||
Gzip
|
||||
</Button>
|
||||
<span class="flex-1" />
|
||||
<label v-if="isTextRedisFormat(stringValueView)" class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
|
|
@ -2147,6 +2274,35 @@ defineExpose({ focusSearch });
|
|||
</div>
|
||||
<pre v-else-if="stringValueView === 'base64' && 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 whitespace-pre-wrap break-all" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else-if="stringValueView === 'base64'" 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 whitespace-pre-wrap break-all">{{ stringValueDetail.base64Text }}</pre>
|
||||
<div v-else-if="stringValueView === 'decompressed'" class="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<div v-if="decompressedState.status === 'loading'" class="flex min-h-0 flex-1 items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("redis.decompressedLoading") }}
|
||||
</div>
|
||||
<div v-else-if="decompressedState.status === 'success'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background">
|
||||
<div v-if="decompressedJsonDetail" class="p-4">
|
||||
<JsonTree :value="decompressedJsonDetail.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<pre v-else class="w-full min-w-0 max-w-full p-4 text-sm leading-6" :class="detailTextClass('decompressed')">{{ decompressedState.text }}</pre>
|
||||
</div>
|
||||
<template v-else>
|
||||
<pre 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('decompressed')">{{ decompressedRawFallbackText }}</pre>
|
||||
<div v-if="decompressedFailureMessage" class="flex shrink-0 flex-wrap items-center gap-2 border-t px-4 py-2 text-xs text-muted-foreground">
|
||||
<span>{{ decompressedFailureMessage }}</span>
|
||||
<Button
|
||||
v-if="decompressedState.status === 'error' && decompressedState.reason !== 'limit'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 rounded-[5px] px-2 text-xs"
|
||||
:title="t('redis.decompressedRetryAsDeflate')"
|
||||
:aria-label="t('redis.decompressedRetryAsDeflate')"
|
||||
@click="retryDecompressAsDeflate"
|
||||
>
|
||||
{{ t("redis.decompressedRetryAsDeflate") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<textarea
|
||||
v-else-if="stringValueView === 'utf8' && canEditCurrentStringFormat"
|
||||
ref="stringTextareaRef"
|
||||
|
|
@ -2706,6 +2862,10 @@ defineExpose({ focusSearch });
|
|||
<DialogTitle class="flex items-center gap-2">
|
||||
<span class="truncate">{{ selectedMemberTitle ? formatValue(selectedMemberTitle) : t("redis.memberDetail") }}</span>
|
||||
<Badge variant="outline" class="shrink-0 text-xs">{{ redisFormatLabel(memberValueView, selectedMemberDetail.rawLabel) }}</Badge>
|
||||
<Badge v-if="memberGzipBadge" variant="outline" class="shrink-0 cursor-pointer text-xs text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" @click="setMemberValueFormat('decompressed')">
|
||||
<FileArchive class="h-3 w-3 mr-1" />
|
||||
Gzip
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<template v-if="isEditingMember">
|
||||
|
|
@ -2752,6 +2912,35 @@ defineExpose({ focusSearch });
|
|||
</div>
|
||||
<pre v-else-if="memberValueView === 'base64' && canHighlightMemberSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6 whitespace-pre-wrap break-all" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else-if="memberValueView === 'base64'" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6 whitespace-pre-wrap break-all">{{ selectedMemberDetail.base64Text }}</pre>
|
||||
<div v-else-if="memberValueView === 'decompressed'" class="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<div v-if="decompressedState.status === 'loading'" class="flex min-h-0 flex-1 items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("redis.decompressedLoading") }}
|
||||
</div>
|
||||
<div v-else-if="decompressedState.status === 'success'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background">
|
||||
<div v-if="decompressedJsonDetail" class="p-5">
|
||||
<JsonTree :value="decompressedJsonDetail.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<pre v-else class="w-full min-w-0 max-w-full p-5 text-[13px] leading-6" :class="detailTextClass('decompressed')">{{ decompressedState.text }}</pre>
|
||||
</div>
|
||||
<template v-else>
|
||||
<pre class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass('decompressed')">{{ decompressedRawFallbackText }}</pre>
|
||||
<div v-if="decompressedFailureMessage" class="flex shrink-0 flex-wrap items-center gap-2 border-t px-5 py-2 text-xs text-muted-foreground">
|
||||
<span>{{ decompressedFailureMessage }}</span>
|
||||
<Button
|
||||
v-if="decompressedState.status === 'error' && decompressedState.reason !== 'limit'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 rounded-[5px] px-2 text-xs"
|
||||
:title="t('redis.decompressedRetryAsDeflate')"
|
||||
:aria-label="t('redis.decompressedRetryAsDeflate')"
|
||||
@click="retryDecompressAsDeflate"
|
||||
>
|
||||
{{ t("redis.decompressedRetryAsDeflate") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<pre v-else-if="canHighlightMemberSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass(memberValueView)" 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-5 text-[13px] leading-6" :class="detailTextClass(memberValueView)">{{ detailTextForFormat(selectedMemberDetail, memberValueView) }}</pre>
|
||||
</template>
|
||||
|
|
@ -2780,7 +2969,7 @@ defineExpose({ focusSearch });
|
|||
<Pencil class="h-4 w-4" />
|
||||
{{ t("redis.editMember") }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="copyText(redisClipboardSafeText(detailTextForFormat(selectedMemberDetail, memberValueView)))">
|
||||
<Button variant="outline" @click="copyText(redisClipboardSafeText(memberCopyText))">
|
||||
<Copy class="h-4 w-4" />
|
||||
{{ t("redis.copyMember") }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3598,6 +3598,12 @@ export default {
|
|||
jsonView: "JSON view",
|
||||
rawContent: "Raw content",
|
||||
wordWrap: "Word wrap",
|
||||
decompressedView: "Decompressed",
|
||||
decompressedLoading: "Decompressing…",
|
||||
decompressedFailed: "Unable to decompress with the selected format",
|
||||
decompressedRetryAsDeflate: "Retry as DEFLATE",
|
||||
decompressedLimitExceeded: "Decompressed result exceeds the 50 MiB limit",
|
||||
gzipBadgeTitle: "Gzip compressed — click to view decompressed content",
|
||||
formatJson: "Format",
|
||||
compressJson: "Compress",
|
||||
jsonFormatError: "Invalid JSON format",
|
||||
|
|
|
|||
|
|
@ -3447,6 +3447,12 @@ export default withEnglishFallback({
|
|||
jsonView: "Vista JSON",
|
||||
rawContent: "Contenido original",
|
||||
wordWrap: "Ajuste de línea",
|
||||
decompressedView: "Descomprimido",
|
||||
decompressedLoading: "Descomprimiendo…",
|
||||
decompressedFailed: "No se puede descomprimir con el formato seleccionado",
|
||||
decompressedRetryAsDeflate: "Reintentar como DEFLATE",
|
||||
decompressedLimitExceeded: "El resultado descomprimido supera el límite de 50 MiB",
|
||||
gzipBadgeTitle: "Comprimido con Gzip: haga clic para ver el contenido descomprimido",
|
||||
clearHistory: "Borrar historial de comandos",
|
||||
historyCleared: "Historial de comandos Redis borrado",
|
||||
blockedCommand: "El comando {command} está bloqueado por seguridad. Desactiva el icono de escudo en la barra de herramientas para permitirlo.",
|
||||
|
|
|
|||
|
|
@ -3447,6 +3447,12 @@ export default withEnglishFallback({
|
|||
jsonView: "Visualizzazione JSON",
|
||||
rawContent: "Contenuto grezzo",
|
||||
wordWrap: "A capo automatico",
|
||||
decompressedView: "Decompresso",
|
||||
decompressedLoading: "Decompressione in corso…",
|
||||
decompressedFailed: "Impossibile decomprimere con il formato selezionato",
|
||||
decompressedRetryAsDeflate: "Riprova come DEFLATE",
|
||||
decompressedLimitExceeded: "Il risultato decompresso supera il limite di 50 MiB",
|
||||
gzipBadgeTitle: "Compresso con Gzip: fare clic per visualizzare il contenuto decompresso",
|
||||
formatJson: "Formatta",
|
||||
compressJson: "Comprimi",
|
||||
jsonFormatError: "Formato JSON non valido",
|
||||
|
|
|
|||
|
|
@ -3479,6 +3479,12 @@ export default withEnglishFallback({
|
|||
jsonView: "JSONビュー",
|
||||
rawContent: "生コンテンツ",
|
||||
wordWrap: "折り返し",
|
||||
decompressedView: "解凍ビュー",
|
||||
decompressedLoading: "解凍中…",
|
||||
decompressedFailed: "選択した形式で解凍できません",
|
||||
decompressedRetryAsDeflate: "Deflate で再試行",
|
||||
decompressedLimitExceeded: "解凍結果が 50 MiB の上限を超えています",
|
||||
gzipBadgeTitle: "Gzip 圧縮 — クリックで解凍内容を表示",
|
||||
formatJson: "フォーマット",
|
||||
compressJson: "圧縮",
|
||||
jsonFormatError: "不正なJSON形式です",
|
||||
|
|
|
|||
|
|
@ -3212,6 +3212,12 @@ export default withEnglishFallback({
|
|||
jsonView: "JSON 보기",
|
||||
rawContent: "원시 내용",
|
||||
wordWrap: "자동 줄바꿈",
|
||||
decompressedView: "압축 해제 보기",
|
||||
decompressedLoading: "압축 해제 중…",
|
||||
decompressedFailed: "선택한 형식으로 압축을 해제할 수 없습니다",
|
||||
decompressedRetryAsDeflate: "Deflate로 재시도",
|
||||
decompressedLimitExceeded: "압축 해제 결과가 50 MiB 제한을 초과합니다",
|
||||
gzipBadgeTitle: "Gzip 압축 — 클릭하여 압축 해제 내용 보기",
|
||||
formatJson: "서식",
|
||||
compressJson: "압축",
|
||||
jsonFormatError: "잘못된 JSON 형식",
|
||||
|
|
|
|||
|
|
@ -3449,6 +3449,12 @@ export default withEnglishFallback({
|
|||
jsonView: "Visão JSON",
|
||||
rawContent: "Conteúdo bruto",
|
||||
wordWrap: "Quebra de linha",
|
||||
decompressedView: "Descomprimido",
|
||||
decompressedLoading: "Descomprimindo…",
|
||||
decompressedFailed: "Não foi possível descomprimir com o formato selecionado",
|
||||
decompressedRetryAsDeflate: "Tentar novamente como DEFLATE",
|
||||
decompressedLimitExceeded: "O resultado descomprimido excede o limite de 50 MiB",
|
||||
gzipBadgeTitle: "Comprimido com Gzip — clique para ver o conteúdo descomprimido",
|
||||
formatJson: "Formatar",
|
||||
compressJson: "Comprimir",
|
||||
jsonFormatError: "Formato JSON inválido",
|
||||
|
|
|
|||
|
|
@ -3598,6 +3598,12 @@ export default withEnglishFallback({
|
|||
jsonView: "JSON 视图",
|
||||
rawContent: "原始内容",
|
||||
wordWrap: "自动换行",
|
||||
decompressedView: "解压视图",
|
||||
decompressedLoading: "解压中…",
|
||||
decompressedFailed: "无法按所选格式解压",
|
||||
decompressedRetryAsDeflate: "以 Deflate 重试",
|
||||
decompressedLimitExceeded: "解压结果超过 50 MiB 限制",
|
||||
gzipBadgeTitle: "Gzip 压缩 — 点击查看解压内容",
|
||||
formatJson: "格式化",
|
||||
compressJson: "压缩",
|
||||
jsonFormatError: "JSON 格式不合法",
|
||||
|
|
|
|||
|
|
@ -2917,6 +2917,12 @@ export default withEnglishFallback({
|
|||
jsonView: "JSON 檢視",
|
||||
rawContent: "原始內容",
|
||||
wordWrap: "自動換行",
|
||||
decompressedView: "解壓檢視",
|
||||
decompressedLoading: "解壓中…",
|
||||
decompressedFailed: "無法按所選格式解壓",
|
||||
decompressedRetryAsDeflate: "以 Deflate 重試",
|
||||
decompressedLimitExceeded: "解壓結果超過 50 MiB 限制",
|
||||
gzipBadgeTitle: "Gzip 壓縮 — 點擊檢視解壓內容",
|
||||
formatJson: "格式化",
|
||||
compressJson: "壓縮",
|
||||
jsonFormatError: "無效的 JSON 格式",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { deflateRawSync, deflateSync, gzipSync } from "zlib";
|
||||
import { decompressRedisValue, isGzipMagic, REDIS_DECOMPRESS_MAX_OUTPUT_BYTES } from "../redisCompression";
|
||||
|
||||
// Decompression runs against the real pako implementation (pure JS), so these
|
||||
// tests exercise the actual production path in Node the same way it runs in the
|
||||
// Tauri WebView2 / WKWebView renderer — no DecompressionStream mock, no
|
||||
// environment-specific behavior to paper over. node:zlib is used only to build
|
||||
// fixtures (it is byte-compatible with pako for gzip/zlib/raw DEFLATE).
|
||||
|
||||
function gzipOf(text: string): Uint8Array {
|
||||
return new Uint8Array(gzipSync(text));
|
||||
}
|
||||
|
||||
function zlibOf(text: string): Uint8Array {
|
||||
return new Uint8Array(deflateSync(text));
|
||||
}
|
||||
|
||||
function deflateRawOf(data: Uint8Array | string): Uint8Array {
|
||||
return new Uint8Array(deflateRawSync(data));
|
||||
}
|
||||
|
||||
describe("isGzipMagic", () => {
|
||||
it("detects the gzip header", () => {
|
||||
expect(isGzipMagic(gzipOf("hello"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-gzip payloads", () => {
|
||||
expect(isGzipMagic(new Uint8Array([0x78, 0x9c, 0x01, 0x00]))).toBe(false);
|
||||
expect(isGzipMagic(new Uint8Array(0))).toBe(false);
|
||||
expect(isGzipMagic(new Uint8Array([0x1f]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decompressRedisValue", () => {
|
||||
it("decompresses gzip via magic detection", async () => {
|
||||
const result = await decompressRedisValue(gzipOf('{"a":1}'));
|
||||
expect(result).toEqual({ ok: true, text: '{"a":1}', algorithm: "gzip" });
|
||||
});
|
||||
|
||||
it("decompresses zlib-wrapped deflate", async () => {
|
||||
const result = await decompressRedisValue(zlibOf("hello world"));
|
||||
expect(result).toEqual({ ok: true, text: "hello world", algorithm: "zlib" });
|
||||
});
|
||||
|
||||
it("does not auto-detect raw deflate from a valid raw-deflate stream", async () => {
|
||||
// A valid raw DEFLATE stream has no framing and no checksum, so arbitrary
|
||||
// binary can look like it. Auto-detection must not accept it — regression
|
||||
// for the old behavior that fell back to raw deflate after zlib failed and
|
||||
// displayed the "decompressed" garbage as real content.
|
||||
const rawDeflate = deflateRawOf("raw deflate payload");
|
||||
const result = await decompressRedisValue(rawDeflate);
|
||||
expect(result).toEqual({ ok: false, reason: "corrupt" });
|
||||
});
|
||||
|
||||
it("rejects arbitrary binary that happens to be a valid raw-deflate stream", async () => {
|
||||
// This is the exact false-positive the reviewer flagged: a value that was
|
||||
// never meant to be compressed data (a binary payload — a PNG header plus
|
||||
// non-UTF-8 bytes) gets raw-deflated, producing a structurally valid
|
||||
// bitstream. Under the old code it was accepted and shown as decompressed
|
||||
// garbage; auto-detection must now reject it.
|
||||
const binaryPayload = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a, // PNG signature
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0xff,
|
||||
0x00,
|
||||
0xff,
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
0x7f,
|
||||
0x80,
|
||||
0xc0,
|
||||
0xe0,
|
||||
0xfe,
|
||||
]);
|
||||
const rawDeflate = deflateRawOf(binaryPayload);
|
||||
const result = await decompressRedisValue(rawDeflate);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("decompresses raw deflate only when the algorithm is explicitly requested", async () => {
|
||||
const rawDeflate = deflateRawOf("explicit raw deflate payload");
|
||||
expect(await decompressRedisValue(rawDeflate)).toEqual({ ok: false, reason: "corrupt" });
|
||||
const result = await decompressRedisValue(rawDeflate, { algorithm: "deflate" });
|
||||
expect(result).toEqual({ ok: true, text: "explicit raw deflate payload", algorithm: "deflate" });
|
||||
});
|
||||
|
||||
it("forces a single algorithm when requested, bypassing magic detection", async () => {
|
||||
const gz = gzipOf("gzip data");
|
||||
const zl = zlibOf("zlib data");
|
||||
// A gzip value forced as zlib must fail, and vice versa — no cross-format
|
||||
// guessing when the caller commits to an algorithm.
|
||||
expect(await decompressRedisValue(gz, { algorithm: "zlib" })).toEqual({ ok: false, reason: "corrupt" });
|
||||
expect(await decompressRedisValue(zl, { algorithm: "gzip" })).toEqual({ ok: false, reason: "corrupt" });
|
||||
expect(await decompressRedisValue(gz, { algorithm: "gzip" })).toEqual({ ok: true, text: "gzip data", algorithm: "gzip" });
|
||||
expect(await decompressRedisValue(zl, { algorithm: "zlib" })).toEqual({ ok: true, text: "zlib data", algorithm: "zlib" });
|
||||
});
|
||||
|
||||
it("reports corrupt data without throwing", async () => {
|
||||
const result = await decompressRedisValue(new Uint8Array([0x1f, 0x8b, 0x00, 0x00, 0x00]));
|
||||
expect(result).toEqual({ ok: false, reason: "corrupt" });
|
||||
});
|
||||
|
||||
it("reports corrupt for plain text that is not compressed", async () => {
|
||||
const result = await decompressRedisValue(new TextEncoder().encode("just plain text"));
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reports corrupt for empty input", async () => {
|
||||
const result = await decompressRedisValue(new Uint8Array(0));
|
||||
expect(result).toEqual({ ok: false, reason: "corrupt" });
|
||||
});
|
||||
|
||||
it("enforces the output cap during decompression for high-ratio input", async () => {
|
||||
// 64 MiB of zeros compresses to ~64 KiB (~1000:1). The bounded inflate
|
||||
// streams output in chunks and aborts as soon as cumulative output exceeds
|
||||
// the cap — the 64 MiB is never materialized. Runs against real pako, so it
|
||||
// reflects actual platform behavior rather than a mock that buffers the
|
||||
// whole result first.
|
||||
const payload = new Uint8Array(64 * 1024 * 1024);
|
||||
const cases: Array<[Uint8Array, Parameters<typeof decompressRedisValue>[1]]> = [
|
||||
[new Uint8Array(gzipSync(payload)), undefined],
|
||||
[new Uint8Array(deflateSync(payload)), undefined],
|
||||
[deflateRawOf(payload), { algorithm: "deflate" }],
|
||||
];
|
||||
for (const [compressed, options] of cases) {
|
||||
const result = await decompressRedisValue(compressed, { ...options, maxOutputBytes: 1024 });
|
||||
expect(result).toEqual({ ok: false, reason: "limit" });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows output up to exactly the cap", async () => {
|
||||
const payload = new Uint8Array(1024);
|
||||
const result = await decompressRedisValue(new Uint8Array(gzipSync(payload)), { maxOutputBytes: 1024 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.algorithm).toBe("gzip");
|
||||
});
|
||||
|
||||
it("respects the shared default cap constant", () => {
|
||||
expect(REDIS_DECOMPRESS_MAX_OUTPUT_BYTES).toBe(50 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("honors a custom cap below the default", async () => {
|
||||
const result = await decompressRedisValue(gzipOf("small payload"), { maxOutputBytes: 8 });
|
||||
expect(result).toEqual({ ok: false, reason: "limit" });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { Inflate as PakoInflate, Z_OK } from "pako";
|
||||
|
||||
export const REDIS_DECOMPRESS_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export type RedisDecompressAlgorithm = "gzip" | "zlib" | "deflate";
|
||||
|
||||
/** Reliable gzip header — the only compression signature we trust for auto-detection. */
|
||||
export function isGzipMagic(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
|
||||
}
|
||||
|
||||
export type RedisDecompressResult =
|
||||
| {
|
||||
ok: true;
|
||||
text: string;
|
||||
algorithm: RedisDecompressAlgorithm;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "corrupt" | "limit";
|
||||
};
|
||||
|
||||
export type RedisDecompressOptions = {
|
||||
maxOutputBytes?: number;
|
||||
/**
|
||||
* Force a specific compression format instead of auto-detecting.
|
||||
* Auto-detection recognizes only gzip (magic bytes) and zlib (RFC 1950 header
|
||||
* + ADLER32 checksum). Raw deflate (`"deflate"`, RFC 1951) has no framing or
|
||||
* checksum, so arbitrary binary data can be misread as valid output — pass
|
||||
* this only when the value is known to be raw DEFLATE.
|
||||
*/
|
||||
algorithm?: RedisDecompressAlgorithm;
|
||||
};
|
||||
|
||||
class DecompressionLimitError extends Error {
|
||||
readonly limitBytes: number;
|
||||
constructor(limitBytes: number) {
|
||||
super(`Decompressed output exceeds ${limitBytes} bytes`);
|
||||
this.name = "DecompressionLimitError";
|
||||
this.limitBytes = limitBytes;
|
||||
}
|
||||
}
|
||||
|
||||
// pako windowBits per format: gzip = 31 (RFC 1952), zlib = 15 (RFC 1950),
|
||||
// raw deflate = -15 (RFC 1951).
|
||||
const WINDOW_BITS: Record<RedisDecompressAlgorithm, number> = {
|
||||
gzip: 31,
|
||||
zlib: 15,
|
||||
deflate: -15,
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounded inflate via pako's streaming `Inflate`. Output arrives chunk by chunk
|
||||
* through `onData`; we count it and throw the moment cumulative output exceeds
|
||||
* `maxOutputBytes`. Because pako emits chunks incrementally (its zlib strm uses
|
||||
* a fixed-size output buffer), a zip bomb aborts after the first chunks past
|
||||
* the cap — the full output is never materialized, so peak memory stays
|
||||
* ~cap + chunk + input instead of unbounded. This is an allocation-time limit,
|
||||
* unlike counting bytes only after a decompressor has already buffered the
|
||||
* whole result internally.
|
||||
*/
|
||||
function inflateBounded(bytes: Uint8Array, algorithm: RedisDecompressAlgorithm, maxOutputBytes: number): Uint8Array {
|
||||
const inflater = new PakoInflate({ windowBits: WINDOW_BITS[algorithm] });
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
inflater.onData = (chunk) => {
|
||||
total += chunk.byteLength;
|
||||
if (total > maxOutputBytes) throw new DecompressionLimitError(maxOutputBytes);
|
||||
chunks.push(chunk);
|
||||
};
|
||||
inflater.push(bytes, true);
|
||||
if (inflater.err !== Z_OK) {
|
||||
throw new Error(inflater.msg || `decompression failed (status ${inflater.err})`);
|
||||
}
|
||||
return concatBytes(chunks);
|
||||
}
|
||||
|
||||
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
||||
let total = 0;
|
||||
for (const chunk of chunks) total += chunk.byteLength;
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function decodeText(bytes: Uint8Array): string {
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress Redis value bytes with the standard web formats.
|
||||
*
|
||||
* Detection order (when no `algorithm` is forced):
|
||||
* 1. gzip — reliable magic (`1f 8b`); a failure here means corrupt data.
|
||||
* 2. zlib — RFC 1950 header + ADLER32 trailer validate on success, so a
|
||||
* successful zlib decode is checksum-verified.
|
||||
* Raw deflate (RFC 1951) is NEVER auto-detected: it has no framing or checksum,
|
||||
* so arbitrary binary can be accepted and shown, searched, or copied as
|
||||
* "decompressed" content. Callers that know a value is raw DEFLATE must pass
|
||||
* `{ algorithm: "deflate" }` explicitly.
|
||||
*
|
||||
* The `ok` result carries the algorithm that actually succeeded so the UI can
|
||||
* label it (e.g. "Decompressed (zlib)").
|
||||
*/
|
||||
export async function decompressRedisValue(bytes: Uint8Array, options: RedisDecompressOptions = {}): Promise<RedisDecompressResult> {
|
||||
const maxOutputBytes = options.maxOutputBytes ?? REDIS_DECOMPRESS_MAX_OUTPUT_BYTES;
|
||||
if (bytes.length === 0) return { ok: false, reason: "corrupt" };
|
||||
|
||||
if (options.algorithm) {
|
||||
return decompressOne(bytes, options.algorithm, maxOutputBytes);
|
||||
}
|
||||
|
||||
if (isGzipMagic(bytes)) {
|
||||
return decompressOne(bytes, "gzip", maxOutputBytes);
|
||||
}
|
||||
|
||||
return decompressOne(bytes, "zlib", maxOutputBytes);
|
||||
}
|
||||
|
||||
async function decompressOne(bytes: Uint8Array, algorithm: RedisDecompressAlgorithm, maxOutputBytes: number): Promise<RedisDecompressResult> {
|
||||
// Yield so the UI can paint the loading state before the synchronous inflate.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
try {
|
||||
const output = inflateBounded(bytes, algorithm, maxOutputBytes);
|
||||
return { ok: true, text: decodeText(output), algorithm };
|
||||
} catch (error) {
|
||||
if (error instanceof DecompressionLimitError) return { ok: false, reason: "limit" };
|
||||
return { ok: false, reason: "corrupt" };
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,10 @@ import { formatJsonSource, parseJsonPreservingLargeNumbers } from "@/lib/common/
|
|||
import type { RedisBlob, RedisCollectionPage, RedisHashItem, RedisListItem, RedisSetItem, RedisValue, RedisZsetItem } from "@/lib/backend/api";
|
||||
import { parseJavaSerializedDetail, type RedisJavaSerializedDetail } from "@/lib/redis/javaSerialized";
|
||||
|
||||
export type RedisValueFormat = "utf8" | "ascii" | "binary" | "json" | "javaserialize" | "hex" | "base64";
|
||||
export type RedisValueFormat = "utf8" | "ascii" | "binary" | "json" | "javaserialize" | "hex" | "base64" | "decompressed";
|
||||
export type RedisMemberDetailFormat = "json" | "text";
|
||||
|
||||
export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64"];
|
||||
export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64", "decompressed"];
|
||||
|
||||
export interface RedisMemberDetail {
|
||||
text: string;
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@
|
|||
fetcherVersion = 4;
|
||||
# Update with the hash reported by a failed fixed-output build:
|
||||
# nix build .#dbx-pnpm-deps 2>&1 | grep 'got:'
|
||||
hash = "sha256-NKYI5zHV7rnFhK2Elm2On6ffRJty4BbJcYaW9gRqPa8=";
|
||||
hash = "sha256-h4KlJml4J7nIyPQRcpSJ1b3buwt81B3gIaL5Ekj38xo=";
|
||||
};
|
||||
|
||||
# ── Step 2: vendor Cargo dependencies ───────────────────────────── #
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@
|
|||
"elkjs": "^0.11.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"marked": "^18.0.4",
|
||||
"pako": "^3.0.1",
|
||||
"pinia": "^3.0.0",
|
||||
"proj4": "^2.20.9",
|
||||
"reka-ui": "^2.10.1",
|
||||
|
|
|
|||
|
|
@ -155,6 +155,9 @@ importers:
|
|||
marked:
|
||||
specifier: ^18.0.4
|
||||
version: 18.0.4
|
||||
pako:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
pinia:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3))
|
||||
|
|
@ -3170,6 +3173,9 @@ packages:
|
|||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
pako@3.0.1:
|
||||
resolution: {integrity: sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -6735,6 +6741,8 @@ snapshots:
|
|||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
pako@3.0.1: {}
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue