feat(redis): configure value preview limit (#12089)

* feat(redis): configure value preview limit

* docs(testing): record Redis preview limit verification

* refactor(redis): move preview limit to connection advanced settings

* docs(testing): update Redis advanced setting screenshot

* refactor(redis): place preview limit at bottom of advanced settings
This commit is contained in:
zipg
2026-10-15 16:43:57 +08:00
committed by GitHub
parent 74b825f109
commit d51926a7e3
32 changed files with 429 additions and 26 deletions
@@ -55,6 +55,7 @@ import { createConnectionNoteVisibilityDraft, persistConnectionNoteVisibilityDra
import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern";
import { normalizeRedisKeyTemplates, redisKeyTemplatesToTextarea } from "@/lib/redis/redisKeyTemplates";
import { normalizeRedisDatabaseValue } from "@/lib/redis/redisDatabaseIndex";
import { REDIS_VALUE_PREVIEW_LIMIT_KIB_MAX, REDIS_VALUE_PREVIEW_LIMIT_KIB_MIN, normalizeRedisValuePreviewLimitKiB } from "@/lib/redis/redisValuePreviewLimit";
import { normalizeGlobalConnectTimeoutSecs, normalizeGlobalQueryTimeoutSecs, useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
@@ -295,6 +296,7 @@ const showConnectionNotesInSidebar = computed({
});
const editGlobalConnectTimeoutSecs = ref(settingsStore.editorSettings.globalConnectTimeoutSecs);
const editGlobalQueryTimeoutSecs = ref(settingsStore.editorSettings.globalQueryTimeoutSecs);
const editRedisValuePreviewLimitKiB = ref(settingsStore.editorSettings.redisValuePreviewLimitKiB);
const open = defineModel<boolean>("open", { default: false });
const isDesktop = isTauriRuntime();
@@ -3085,6 +3087,7 @@ watch(
resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes);
editGlobalConnectTimeoutSecs.value = settingsStore.editorSettings.globalConnectTimeoutSecs;
editGlobalQueryTimeoutSecs.value = settingsStore.editorSettings.globalQueryTimeoutSecs;
editRedisValuePreviewLimitKiB.value = settingsStore.editorSettings.redisValuePreviewLimitKiB;
if (syncAction === "hydrate" && config) {
appliedConnectionUpdate = connectionUpdate;
if (connectionUpdate) applyingConnectionUpdate = true;
@@ -6176,6 +6179,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) {
resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes);
editGlobalConnectTimeoutSecs.value = settingsStore.editorSettings.globalConnectTimeoutSecs;
editGlobalQueryTimeoutSecs.value = settingsStore.editorSettings.globalQueryTimeoutSecs;
editRedisValuePreviewLimitKiB.value = settingsStore.editorSettings.redisValuePreviewLimitKiB;
selectedTransportLayerId.value = null;
draggedTransportLayerId.value = null;
selectedType.value = "mysql";
@@ -6623,6 +6627,13 @@ async function persistGlobalTimeoutDrafts() {
});
}
async function persistGlobalRedisValuePreviewLimit() {
const next = normalizeRedisValuePreviewLimitKiB(editRedisValuePreviewLimitKiB.value);
editRedisValuePreviewLimitKiB.value = next;
if (next === settingsStore.editorSettings.redisValuePreviewLimitKiB) return;
await settingsStore.updateEditorSettingsAndPersist({ redisValuePreviewLimitKiB: next });
}
async function persistConnectionNoteVisibilityDraft() {
await persistConnectionNoteVisibilityDraftState(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes, (value) => settingsStore.updateEditorSettingsAndPersist({ sidebarShowConnectionNotes: value }));
}
@@ -6674,6 +6685,7 @@ async function save(options: SaveConnectionOptions = {}): Promise<boolean> {
await ensureRequiredGaussdbMJdbcRuntime(updated);
validateConnectionUpdateTarget();
await persistGlobalTimeoutDrafts();
await persistGlobalRedisValuePreviewLimit();
await store.updateConnection(updated);
savedConfig = updated;
connectionSaved = true;
@@ -6683,6 +6695,7 @@ async function save(options: SaveConnectionOptions = {}): Promise<boolean> {
await ensureRequiredAgentDriverInstalled(config);
await ensureRequiredGaussdbMJdbcRuntime(config);
await persistGlobalTimeoutDrafts();
await persistGlobalRedisValuePreviewLimit();
await store.addConnection(config, selectedConnectionGroupId.value);
savedConfig = config;
connectionSaved = true;
@@ -10420,6 +10433,16 @@ function openExternalUrl(url: string) {
<p class="text-xs text-muted-foreground">{{ t("settings.redisScanPageSizeDescription") }}</p>
</div>
</div>
<div v-if="form.db_type === 'redis'" class="grid grid-cols-4 items-start gap-4">
<Label for="redis-value-preview-limit-input" :class="connectionLabelTopClass">{{ t("settings.redisValuePreviewLimit") }}</Label>
<div class="col-span-3 space-y-1">
<div class="flex items-center gap-2">
<Input id="redis-value-preview-limit-input" v-model.number="editRedisValuePreviewLimitKiB" type="number" :min="REDIS_VALUE_PREVIEW_LIMIT_KIB_MIN" :max="REDIS_VALUE_PREVIEW_LIMIT_KIB_MAX" step="1" class="h-8 w-32 text-xs" />
<span class="text-xs text-muted-foreground">KiB</span>
</div>
<p class="text-xs text-muted-foreground">{{ t("settings.redisValuePreviewLimitDescription") }}</p>
</div>
</div>
</div>
</TabsContent>
@@ -171,7 +171,7 @@ async function settle() {
}
async function mountDialog(config: ConnectionConfig, url: string, initiallyOpen = true) {
const props = reactive({ open: initiallyOpen, editConfig: config, updatePrefill: parseConnectionDeepLinkUpdate(url) });
const props = reactive({ open: initiallyOpen, editConfig: config, updatePrefill: url ? parseConnectionDeepLinkUpdate(url) : null });
store.getConfig.mockReturnValue(config);
const container = document.createElement("div");
document.body.append(container);
@@ -209,6 +209,25 @@ afterEach(() => {
});
describe("ConnectionDialog deep-link edit confirmation", () => {
it("shows the global Redis preview limit in advanced settings and persists it", async () => {
visibleTab.value = "advanced";
settings.editorSettings.redisValuePreviewLimitKiB = 64;
const redis = savedConnection({ id: "redis-connection", name: "Redis", db_type: "redis", driver_profile: "redis", driver_label: "Redis", host: "127.0.0.1", port: 6379, database: "0" });
await mountDialog(redis, "");
const input = document.querySelector<HTMLInputElement>("#redis-value-preview-limit-input");
expect(input?.value).toBe("64");
expect(input?.min).toBe("1");
expect(input?.max).toBe("65536");
input!.value = "256";
input!.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
save!.click();
await settle();
expect(settings.updateEditorSettingsAndPersist).toHaveBeenCalledWith({ redisValuePreviewLimitKiB: 256 });
expect(store.updateConnection).toHaveBeenCalled();
});
it("changes the advanced badge picker and persists it while retaining production protection", async () => {
visibleTab.value = "advanced";
await mountDialog(savedConnection({ production_badge_scope: "connection" }), "dbx://connection/new?id=saved-production");
@@ -1347,7 +1347,7 @@ describe("RedisKeyBrowser expiry creation", () => {
await submitCreate();
expectWriterBefore(mocks.redisSetString, mocks.redisSetTtl);
expect(mocks.redisGetValue).toHaveBeenCalledWith("connection", 0, KEY_RAW);
expect(mocks.redisGetValue).toHaveBeenCalledWith("connection", 0, KEY_RAW, 65536);
expect(mocks.redisDeleteKey).not.toHaveBeenCalled();
expect(mocks.redisDeleteKeys).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000);
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import DateTimePicker from "@/components/ui/date-time-picker/DateTimePicker.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { redisValuePreviewLimitBytes } from "@/lib/redis/redisValuePreviewLimit";
import RedisValueViewer from "./RedisValueViewer.vue";
import RedisPubSubPanel from "./RedisPubSubPanel.vue";
import RedisSlowlogPanel from "./RedisSlowlogPanel.vue";
@@ -2327,7 +2328,7 @@ function isRedisMissingKeyError(error: unknown): boolean {
}
async function syncWrittenKey(keyRaw: string): Promise<boolean> {
const created = await api.redisGetValue(props.connectionId, props.db, keyRaw);
const created = await api.redisGetValue(props.connectionId, props.db, keyRaw, redisValuePreviewLimitBytes(settingsStore.editorSettings.redisValuePreviewLimitKiB));
if (created.redis_type === "none") {
removeKnownKey(keyRaw);
return false;
@@ -3737,6 +3738,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
:key-display="selectedKey.key_display"
:key-raw="selectedKey.key_raw"
:metadata="selectedKey"
:preview-limit-ki-b="settingsStore.editorSettings.redisValuePreviewLimitKiB"
@deleted="onKeyDeleted"
@renamed="onKeyRenamed"
@loaded="onKeyLoaded"
@@ -179,7 +179,7 @@ const testI18nMessages = {
},
};
function mountViewer(onDeleted: (keyRaw: string) => void, onLoaded = vi.fn()) {
function mountViewer(onDeleted: (keyRaw: string) => void, onLoaded = vi.fn(), previewLimitKiB = ref(64)) {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
@@ -193,6 +193,7 @@ function mountViewer(onDeleted: (keyRaw: string) => void, onLoaded = vi.fn()) {
keyRaw: "key",
onDeleted,
onLoaded,
previewLimitKiB: previewLimitKiB.value,
});
},
}),
@@ -288,6 +289,31 @@ async function setStringDraft(value: string) {
}
describe("RedisValueViewer expiry saving", () => {
it("loads the configured preview size and refreshes when the limit changes", async () => {
const limit = ref(256);
mocks.redisGetValue.mockResolvedValue(stringValue());
mountViewer(vi.fn(), vi.fn(), limit);
await settle();
expect(mocks.redisGetValue).toHaveBeenLastCalledWith("connection", 0, "key", 256 * 1024);
limit.value = 1024;
await settle();
expect(mocks.redisGetValue).toHaveBeenLastCalledWith("connection", 0, "key", 1024 * 1024);
expect(document.querySelector("textarea")?.readOnly).toBe(false);
});
it("preserves unsaved edits when the preview limit changes", async () => {
const limit = ref(64);
mocks.redisGetValue.mockResolvedValue(stringValue());
mountViewer(vi.fn(), vi.fn(), limit);
await settle();
await setStringDraft("unsaved draft");
const calls = mocks.redisGetValue.mock.calls.length;
limit.value = 256;
await settle();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(calls);
expect(document.querySelector("textarea")?.value).toBe("unsaved draft");
});
it("renders a large String as a bounded read-only preview", async () => {
mocks.redisGetValue.mockResolvedValueOnce(largeStringValue());
@@ -231,7 +231,7 @@ describe("RedisValueViewer stream monitoring", () => {
const host = mountViewer();
await settle();
expect(mocks.redisGetValue).toHaveBeenCalledWith("redis-1", 2, "b3JkZXJz");
expect(mocks.redisGetValue).toHaveBeenCalledWith("redis-1", 2, "b3JkZXJz", 65536);
expect(mocks.redisGetStreamGroups).not.toHaveBeenCalled();
openGroups(host);
@@ -73,6 +73,7 @@ import RedisHorizontalScrollbar from "@/components/redis/RedisHorizontalScrollba
import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/lib/common/safeJsonFormat";
import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry";
import { redisValuePreviewLimitBytes } from "@/lib/redis/redisValuePreviewLimit";
import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
import { formatBytes } from "@/lib/database/serverMetrics";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
@@ -89,6 +90,7 @@ const props = defineProps<{
keyDisplay: string;
keyRaw: string;
metadata?: RedisKeyInfo | null;
previewLimitKiB?: number;
}>();
const redisExpiryTransport = {
@@ -120,6 +122,7 @@ const renameKeyError = ref("");
const renameKeyInitialName = ref("");
const renameKeyInitialRaw = ref("");
let loadRequestId = 0;
let loadedPreviewLimitBytes: number | undefined;
const streamTab = ref<"entries" | "groups">("entries");
const streamEntries = ref<RedisStreamEntry[]>([]);
const streamEntriesCursor = ref<string | undefined>();
@@ -1771,10 +1774,10 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
const shouldSelectDefaultMember = options.selectDefaultMember ?? true;
const requestId = ++loadRequestId;
if (!background) stopCollectionSearch();
const loadScope = { connectionId: props.connectionId, db: props.db, keyRaw: props.keyRaw };
const loadScope = { connectionId: props.connectionId, db: props.db, keyRaw: props.keyRaw, previewLimitBytes: redisValuePreviewLimitBytes(props.previewLimitKiB) };
if (!background) loading.value = true;
try {
let loadedValue = await api.redisGetValue(loadScope.connectionId, loadScope.db, loadScope.keyRaw);
let loadedValue = await api.redisGetValue(loadScope.connectionId, loadScope.db, loadScope.keyRaw, loadScope.previewLimitBytes);
if (requestId !== loadRequestId || !redisValueViewerIsActive || (options.shouldApply && !options.shouldApply())) return false;
if (loadedValue.data.kind === "zset" && zsetSortDir.value === "desc") {
const sortedPage = await collectionSearchController.readPage({ ...loadScope, kind: "zset", sortDirection: "desc" }, 0, "", () => requestId === loadRequestId && (!options.shouldApply || options.shouldApply()));
@@ -1828,6 +1831,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
searchLoading.value = false;
resetValueSearch();
data.value = loadedValue;
loadedPreviewLimitBytes = loadScope.previewLimitBytes;
syncCountdownTtl(loadedValue.ttl);
if (notifyParent) emit("loaded", loadedValue);
scanCursor.value = redisValueCollectionScanCursor(loadedValue);
@@ -2920,6 +2924,14 @@ watch(
},
);
watch(
() => props.previewLimitKiB,
() => {
// Apply the new limit immediately, but never replace an edited value.
if (redisValueViewerIsActive && !hasUnsavedRedisDraft.value) void load();
},
);
watch(streamTab, (tab) => {
if (tab === "groups" && redisKind.value === "stream") void loadStreamGroups();
});
@@ -2962,7 +2974,7 @@ onMounted(() => {
onActivated(() => {
redisValueViewerIsActive = true;
collectionSearchController.setActive(true);
if (!data.value && !loading.value) void load();
if (!loading.value && (!data.value || (loadedPreviewLimitBytes !== redisValuePreviewLimitBytes(props.previewLimitKiB) && !hasUnsavedRedisDraft.value))) void load();
startRefreshTimers();
});
onDeactivated(() => {
+2
View File
@@ -9418,6 +9418,8 @@ export default {
redisScanPageSize: "Redis scan count",
redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.",
redisScanPageSizeOption: "{count} keys",
redisValuePreviewLimit: "Value preview limit",
redisValuePreviewLimitDescription: "Maximum String and Bitmap value size loaded in the editor. Default: 64 KiB; range: 1–65536 KiB (64 MiB). Larger values can be downloaded in full. Changing this refreshes open details without unsaved edits.",
redisDatabaseDisplayLimit: "Sidebar database list limit",
redisDatabaseDisplayLimitDescription: 'Maximum number of databases shown at once in the sidebar; the rest can be revealed with "Load more".',
redisDatabaseDisplayLimitOption: "{count} databases",
+2
View File
@@ -8484,6 +8484,8 @@ export default withEnglishFallback({
redisScanPageSize: "Cantidad de escaneo Redis",
redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.",
redisScanPageSizeOption: "{count} claves",
redisValuePreviewLimit: "Límite de vista previa de valores",
redisValuePreviewLimitDescription: "Tamaño máximo de valores String y Bitmap cargados en el editor. Predeterminado: 64 KiB; rango: 1–65536 KiB (64 MiB). Los valores mayores se pueden descargar completos. Al cambiarlo se actualizan los detalles abiertos sin cambios pendientes.",
redisDatabaseDisplayLimit: "Límite de bases de datos en la barra lateral",
redisDatabaseDisplayLimitDescription: 'Número máximo de bases de datos que se muestran a la vez en la barra lateral; el resto se puede ver con "Cargar más".',
redisDatabaseDisplayLimitOption: "{count} bases de datos",
+2
View File
@@ -8848,6 +8848,8 @@ export default withEnglishFallback({
redisScanPageSize: "Jumlah scan Redis",
redisScanPageSizeDescription: "Key yang diminta per halaman SCAN Redis saat menjelajahi key.",
redisScanPageSizeOption: "{count} key",
redisValuePreviewLimit: "Batas pratinjau nilai",
redisValuePreviewLimitDescription: "Ukuran maksimum nilai String dan Bitmap yang dimuat di editor. Default: 64 KiB; rentang: 1–65536 KiB (64 MiB). Nilai yang lebih besar dapat diunduh lengkap. Perubahan menyegarkan detail terbuka tanpa edit yang belum disimpan.",
redisDatabaseDisplayLimit: "Batas daftar database bilah sisi",
redisDatabaseDisplayLimitDescription: 'Jumlah maksimum database yang ditampilkan sekaligus di bilah sisi; sisanya dapat ditampilkan dengan "Muat lebih banyak".',
redisDatabaseDisplayLimitOption: "{count} database",
+2
View File
@@ -8247,6 +8247,8 @@ export default withEnglishFallback({
redisScanPageSize: "Conteggio scansione Redis",
redisScanPageSizeDescription: "Chiavi richieste per pagina SCAN di Redis durante la navigazione delle chiavi.",
redisScanPageSizeOption: "{count} chiavi",
redisValuePreviewLimit: "Limite di anteprima dei valori",
redisValuePreviewLimitDescription: "Dimensione massima dei valori String e Bitmap caricati nell’editor. Predefinito: 64 KiB; intervallo: 1–65536 KiB (64 MiB). I valori più grandi possono essere scaricati per intero. La modifica aggiorna i dettagli aperti senza modifiche non salvate.",
redisDatabaseDisplayLimit: "Limite elenco database nella barra laterale",
redisDatabaseDisplayLimitDescription: 'Numero massimo di database mostrati contemporaneamente nella barra laterale; il resto può essere visualizzato con "Carica altro".',
redisDatabaseDisplayLimitOption: "{count} database",
+2
View File
@@ -8244,6 +8244,8 @@ export default withEnglishFallback({
redisScanPageSize: "Redisスキャンカウント",
redisScanPageSizeDescription: "キー参照時のRedis SCANページあたりの要求キー数。",
redisScanPageSizeOption: "{count}キー",
redisValuePreviewLimit: "値のプレビュー上限",
redisValuePreviewLimitDescription: "エディターに読み込む String と Bitmap 値の最大サイズ。既定値は 64 KiB、範囲は 1~65536 KiB(64 MiB)です。上限を超える値は全体をダウンロードできます。変更すると、未保存の編集がない詳細が更新されます。",
redisDatabaseDisplayLimit: "サイドバーのデータベース一覧の上限",
redisDatabaseDisplayLimitDescription: "サイドバーに一度に表示するデータベースの最大数。残りは「もっと読み込む」で表示できます。",
redisDatabaseDisplayLimitOption: "{count}個のデータベース",
+2
View File
@@ -8125,6 +8125,8 @@ export default withEnglishFallback({
redisScanPageSize: "Redis 스캔 개수",
redisScanPageSizeDescription: "키를 탐색할 때 Redis SCAN 페이지당 요청하는 키 수입니다.",
redisScanPageSizeOption: "키 {count}개",
redisValuePreviewLimit: "값 미리보기 한도",
redisValuePreviewLimitDescription: "편집기에 불러오는 String 및 Bitmap 값의 최대 크기입니다. 기본값: 64 KiB, 범위: 1–65536 KiB(64 MiB). 더 큰 값은 전체를 다운로드할 수 있습니다. 변경하면 저장하지 않은 편집이 없는 열린 상세 정보가 새로 고쳐집니다.",
redisDatabaseDisplayLimit: "사이드바 데이터베이스 목록 제한",
redisDatabaseDisplayLimitDescription: '사이드바에 한 번에 표시할 최대 데이터베이스 수입니다. 나머지는 "더 불러오기"로 볼 수 있습니다.',
redisDatabaseDisplayLimitOption: "데이터베이스 {count}개",
+2
View File
@@ -8245,6 +8245,8 @@ export default withEnglishFallback({
redisScanPageSize: "Contagem de scan do Redis",
redisScanPageSizeDescription: "Chaves solicitadas por página do Redis SCAN ao navegar pelas chaves.",
redisScanPageSizeOption: "{count} chaves",
redisValuePreviewLimit: "Limite de prévia de valores",
redisValuePreviewLimitDescription: "Tamanho máximo dos valores String e Bitmap carregados no editor. Padrão: 64 KiB; intervalo: 1–65536 KiB (64 MiB). Valores maiores podem ser baixados integralmente. A alteração atualiza os detalhes abertos sem edições não salvas.",
redisDatabaseDisplayLimit: "Limite da lista de bancos de dados na barra lateral",
redisDatabaseDisplayLimitDescription: 'Número máximo de bancos de dados exibidos de uma vez na barra lateral; o restante pode ser visto com "Carregar mais".',
redisDatabaseDisplayLimitOption: "{count} bancos de dados",
+2
View File
@@ -9600,6 +9600,8 @@ export default withEnglishFallback({
redisScanPageSize: "Количество ключей при сканировании Redis",
redisScanPageSizeDescription: "Число ключей, запрашиваемых за одну страницу Redis SCAN при просмотре ключей.",
redisScanPageSizeOption: "{count} ключей",
redisValuePreviewLimit: "Лимит предпросмотра значений",
redisValuePreviewLimitDescription: "Максимальный размер значений String и Bitmap, загружаемых в редактор. По умолчанию: 64 КиБ; диапазон: 1–65536 КиБ (64 МиБ). Большие значения можно скачать целиком. Изменение обновляет открытые сведения без несохранённых правок.",
redisDatabaseDisplayLimit: "Лимит списка баз данных в боковой панели",
redisDatabaseDisplayLimitDescription: "Максимальное число баз данных, показываемых сразу в боковой панели; остальные можно показать кнопкой «Загрузить ещё».",
redisDatabaseDisplayLimitOption: "{count} баз данных",
+2
View File
@@ -7943,6 +7943,8 @@ export default withEnglishFallback({
redisScanPageSize: "Redis tarama sayısı",
redisScanPageSizeDescription: "Anahtarlara gözatılırken her Redis SCAN sayfasında istenen anahtar sayısı.",
redisScanPageSizeOption: "{count} anahtar",
redisValuePreviewLimit: "Değer önizleme sınırı",
redisValuePreviewLimitDescription: "Düzenleyicide yüklenen String ve Bitmap değerlerinin en büyük boyutu. Varsayılan: 64 KiB; aralık: 1–65536 KiB (64 MiB). Daha büyük değerler tamamen indirilebilir. Değişiklik, kaydedilmemiş düzenlemesi olmayan açık ayrıntıları yeniler.",
redisDatabaseDisplayLimit: "Kenar çubuğu veritabanı listesi sınırı",
redisDatabaseDisplayLimitDescription: 'Kenar çubuğunda bir seferde gösterilecek maksimum veritabanı sayısı; kalanlar "Daha fazla yükle" ile görüntülenebilir.',
redisDatabaseDisplayLimitOption: "{count} veritabanı",
+2
View File
@@ -9397,6 +9397,8 @@ export default withEnglishFallback({
redisScanPageSize: "Redis 扫描数量",
redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。",
redisScanPageSizeOption: "{count} 个 Key",
redisValuePreviewLimit: "值预览上限",
redisValuePreviewLimitDescription: "String 和 Bitmap 值在编辑器中最多加载的大小。默认 64 KiB,可设置为 1–65536 KiB(64 MiB)。超过上限时可下载完整值;修改后无未保存编辑的详情会自动刷新。",
redisDatabaseDisplayLimit: "侧边栏数据库列表上限",
redisDatabaseDisplayLimitDescription: "侧边栏一次最多展示的数据库数量,超出部分可通过“加载更多”查看。",
redisDatabaseDisplayLimitOption: "{count} 个数据库",
+2
View File
@@ -7587,6 +7587,8 @@ export default withEnglishFallback({
redisScanPageSize: "Redis 掃描數量",
redisScanPageSizeDescription: "瀏覽 Redis Key 時每次 SCAN 請求的 Key 數量。",
redisScanPageSizeOption: "{count} 個 Key",
redisValuePreviewLimit: "值預覽上限",
redisValuePreviewLimitDescription: "String 和 Bitmap 值在編輯器中最多載入的大小。預設 64 KiB,可設為 1–65536 KiB(64 MiB)。超過上限時可下載完整值;修改後沒有未儲存編輯的詳細資料會自動重新整理。",
redisDatabaseDisplayLimit: "側邊欄資料庫清單上限",
redisDatabaseDisplayLimitDescription: "側邊欄一次最多顯示的資料庫數量,超出部分可透過「載入更多」查看。",
redisDatabaseDisplayLimitOption: "{count} 個資料庫",
@@ -18,6 +18,12 @@ describe("Redis expiry Tauri API", () => {
vi.unstubAllGlobals();
});
it("forwards the configured Redis value preview byte limit to Tauri", async () => {
const { redisGetValue } = await import("@/lib/backend/tauri");
await redisGetValue("redis-1", 2, "a2V5", 262144);
expect(mocks.invoke).toHaveBeenCalledWith("redis_get_value", { connectionId: "redis-1", db: 2, keyRaw: "a2V5", previewLimitBytes: 262144 });
});
it("invokes EXPIREAT with the Unix timestamp", async () => {
mocks.invoke.mockResolvedValue(undefined);
const { redisSetExpireAt } = await import("@/lib/backend/tauri");
@@ -91,6 +97,15 @@ describe("Redis expiry HTTP API", () => {
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts the configured preview byte limit and allows older callers to omit it", async () => {
const fetch = stubFetch();
const { redisGetValue } = await import("@/lib/backend/http");
await redisGetValue("redis-1", 2, "a2V5", 262144);
expect(lastCall(fetch)).toEqual({ url: "/api/redis/get-value", body: { connectionId: "redis-1", db: 2, keyRaw: "a2V5", previewLimitBytes: 262144 } });
await redisGetValue("redis-1", 2, "a2V5");
expect(lastCall(fetch)).toEqual({ url: "/api/redis/get-value", body: { connectionId: "redis-1", db: 2, keyRaw: "a2V5" } });
});
it("posts relative TTL, persist, and absolute expiration requests", async () => {
const fetchMock = stubFetch();
const { redisSetExpireAt, redisSetTtl } = await import("@/lib/backend/http");
+2 -2
View File
@@ -3805,8 +3805,8 @@ export async function redisScanValues(connectionId: string, db: number, cursor:
});
}
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
return post("/api/redis/get-value", { connectionId, db, keyRaw });
export async function redisGetValue(connectionId: string, db: number, keyRaw: string, previewLimitBytes?: number): Promise<RedisValue> {
return post("/api/redis/get-value", { connectionId, db, keyRaw, previewLimitBytes });
}
export async function redisGetRawValue(connectionId: string, db: number, keyRaw: string): Promise<RedisBlob> {
+2 -2
View File
@@ -3632,8 +3632,8 @@ export async function redisScanValues(connectionId: string, db: number, cursor:
});
}
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
return invoke("redis_get_value", { connectionId, db, keyRaw });
export async function redisGetValue(connectionId: string, db: number, keyRaw: string, previewLimitBytes?: number): Promise<RedisValue> {
return invoke("redis_get_value", { connectionId, db, keyRaw, previewLimitBytes });
}
export async function redisGetRawValue(connectionId: string, db: number, keyRaw: string): Promise<RedisBlob> {
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { normalizeRedisValuePreviewLimitKiB, redisValuePreviewLimitBytes } from "../redisValuePreviewLimit";
import { DEFAULT_EDITOR_SETTINGS, normalizeEditorSettings } from "@/stores/settingsStore";
describe("Redis value preview setting", () => {
it("preserves the 64 KiB default for existing settings", () => {
expect(DEFAULT_EDITOR_SETTINGS.redisValuePreviewLimitKiB).toBe(64);
expect(normalizeEditorSettings({}).redisValuePreviewLimitKiB).toBe(64);
expect(redisValuePreviewLimitBytes(undefined)).toBe(65536);
});
it.each([undefined, "256", NaN, Infinity, -Infinity])("uses the default for invalid saved values %s", (value) => {
expect(normalizeRedisValuePreviewLimitKiB(value)).toBe(64);
});
it.each([
[0, 1],
[-2, 1],
[256.4, 256],
[256.7, 257],
[65536, 65536],
[100000, 65536],
])("normalizes %s KiB to %s", (value, expected) => {
expect(normalizeEditorSettings({ redisValuePreviewLimitKiB: value }).redisValuePreviewLimitKiB).toBe(expected);
expect(redisValuePreviewLimitBytes(value)).toBe(expected * 1024);
});
});
@@ -0,0 +1,13 @@
export const REDIS_VALUE_PREVIEW_LIMIT_KIB_DEFAULT = 64;
export const REDIS_VALUE_PREVIEW_LIMIT_KIB_MIN = 1;
export const REDIS_VALUE_PREVIEW_LIMIT_KIB_MAX = 65_536;
export const REDIS_VALUE_PREVIEW_LIMIT_KIB_OPTIONS = [64, 256, 1024, 4096, 16_384, 65_536] as const;
export function normalizeRedisValuePreviewLimitKiB(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return REDIS_VALUE_PREVIEW_LIMIT_KIB_DEFAULT;
return Math.min(REDIS_VALUE_PREVIEW_LIMIT_KIB_MAX, Math.max(REDIS_VALUE_PREVIEW_LIMIT_KIB_MIN, Math.round(value)));
}
export function redisValuePreviewLimitBytes(value: unknown): number {
return normalizeRedisValuePreviewLimitKiB(value) * 1024;
}
+6
View File
@@ -23,6 +23,7 @@ import { needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings,
import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort";
import { type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { normalizeRedisValuePreviewLimitKiB, REDIS_VALUE_PREVIEW_LIMIT_KIB_DEFAULT } from "@/lib/redis/redisValuePreviewLimit";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT, REDIS_DATABASE_DISPLAY_LIMIT_MIN, REDIS_DATABASE_DISPLAY_LIMIT_MAX } from "@/lib/redis/redisDatabaseAlias";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
@@ -1185,6 +1186,8 @@ export interface EditorSettings {
redisKeyTemplates: string[];
/** Sidebar database-list cap for Redis connections; the rest are revealed via "load more". */
redisDatabaseDisplayLimit: number;
/** Maximum Redis String/Bitmap preview size in KiB. */
redisValuePreviewLimitKiB: number;
exportRowLimitEnabled: boolean;
exportRowLimit: number;
queryExportKeysetOptimizationEnabled: boolean;
@@ -1512,6 +1515,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
csvNullMode: DEFAULT_CSV_NULL_MODE,
redisKeyTemplates: [],
redisDatabaseDisplayLimit: REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT,
redisValuePreviewLimitKiB: REDIS_VALUE_PREVIEW_LIMIT_KIB_DEFAULT,
exportRowLimitEnabled: false,
exportRowLimit: 100000,
queryExportKeysetOptimizationEnabled: true,
@@ -2209,6 +2213,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
csvQuoteMode: normalizeCsvQuoteMode(settings.csvQuoteMode),
csvNullMode: normalizeCsvNullMode(settings.csvNullMode),
redisKeyTemplates: normalizeRedisKeyTemplates(settings.redisKeyTemplates),
redisValuePreviewLimitKiB: normalizeRedisValuePreviewLimitKiB(settings.redisValuePreviewLimitKiB),
redisDatabaseDisplayLimit:
typeof settings.redisDatabaseDisplayLimit === "number" && settings.redisDatabaseDisplayLimit >= REDIS_DATABASE_DISPLAY_LIMIT_MIN && settings.redisDatabaseDisplayLimit <= REDIS_DATABASE_DISPLAY_LIMIT_MAX
? Math.round(settings.redisDatabaseDisplayLimit)
@@ -3063,6 +3068,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.applyColumnFormattersOnExport !== undefined) editorSettings.value.applyColumnFormattersOnExport = partial.applyColumnFormattersOnExport === true;
if (partial.csvQuoteMode !== undefined) editorSettings.value.csvQuoteMode = normalizeCsvQuoteMode(partial.csvQuoteMode);
if (partial.csvNullMode !== undefined) editorSettings.value.csvNullMode = normalizeCsvNullMode(partial.csvNullMode);
if (partial.redisValuePreviewLimitKiB !== undefined) editorSettings.value.redisValuePreviewLimitKiB = normalizeRedisValuePreviewLimitKiB(partial.redisValuePreviewLimitKiB);
if (partial.redisKeyTemplates !== undefined) editorSettings.value.redisKeyTemplates = normalizeRedisKeyTemplates(partial.redisKeyTemplates);
if (partial.redisDatabaseDisplayLimit !== undefined) editorSettings.value.redisDatabaseDisplayLimit = Math.min(REDIS_DATABASE_DISPLAY_LIMIT_MAX, Math.max(REDIS_DATABASE_DISPLAY_LIMIT_MIN, Math.round(partial.redisDatabaseDisplayLimit)));
if (partial.exportRowLimitEnabled !== undefined) editorSettings.value.exportRowLimitEnabled = partial.exportRowLimitEnabled;
+21 -2
View File
@@ -136,6 +136,18 @@ pub async fn redis_get_value_in_db_core(
db: u32,
key_raw: &str,
) -> Result<RedisValue, String> {
redis_get_value_in_db_with_preview_limit_core(state, connection_id, db, key_raw, None).await
}
pub async fn redis_get_value_in_db_with_preview_limit_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
preview_limit_bytes: Option<usize>,
) -> Result<RedisValue, String> {
let preview_limit_bytes = preview_limit_bytes.unwrap_or(redis_driver::STRING_PREVIEW_MAX_BYTES);
redis_driver::validate_value_preview_limit(preview_limit_bytes)?;
ensure_redis_pool(state, connection_id).await?;
let pool = state.pool_handle(connection_id).await.ok_or("Connection not found")?;
match &pool {
@@ -145,15 +157,22 @@ pub async fn redis_get_value_in_db_core(
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut *con, db).await?;
redis_driver::get_value(&mut *con, &key).await
redis_driver::get_value_scoped_with_preview_limit(
&mut *con,
&key,
&redis_driver::RedisOperationScope { connection_id: connection_id.to_string(), db },
preview_limit_bytes,
)
.await
}
RedisConnection::Cluster(cluster) => {
redis_driver::ensure_cluster_database(cluster, db).await?;
let mut con = redis_driver::cluster_key_connection_in_db(cluster, &key, db).await?;
redis_driver::get_value_scoped(
redis_driver::get_value_scoped_with_preview_limit(
&mut con,
&key,
&redis_driver::RedisOperationScope { connection_id: connection_id.to_string(), db },
preview_limit_bytes,
)
.await
}
+116 -7
View File
@@ -19,7 +19,15 @@ use tokio::sync::{Mutex, MutexGuard};
const STREAM_ENTRY_PAGE_SIZE: usize = 50;
const STREAM_PENDING_PAGE_SIZE: usize = 100;
const COLLECTION_PAGE_SIZE: usize = 200;
const STRING_PREVIEW_MAX_BYTES: usize = 64 * 1024;
pub const STRING_PREVIEW_MAX_BYTES: usize = 64 * 1024;
pub const VALUE_PREVIEW_MAX_BYTES: usize = 64 * 1024 * 1024;
pub fn validate_value_preview_limit(limit: usize) -> Result<(), String> {
if !(1024..=VALUE_PREVIEW_MAX_BYTES).contains(&limit) {
return Err("Redis value preview limit must be between 1024 and 67108864 bytes".to_string());
}
Ok(())
}
/// A collection page is capped by payload size as well as item count.
///
/// `COLLECTION_PAGE_SIZE` alone bounds a page only when the members are small. A hash, list, set
@@ -3239,6 +3247,19 @@ pub async fn get_value_scoped<C>(con: &mut C, key: &[u8], scope: &RedisOperation
where
C: ConnectionLike + Send + Sync + Unpin,
{
get_value_scoped_with_preview_limit(con, key, scope, STRING_PREVIEW_MAX_BYTES).await
}
pub async fn get_value_scoped_with_preview_limit<C>(
con: &mut C,
key: &[u8],
scope: &RedisOperationScope,
preview_limit_bytes: usize,
) -> Result<RedisValue, String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
validate_value_preview_limit(preview_limit_bytes)?;
let redis_type: String = redis::cmd("TYPE").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
// For Streams, fetch metadata and the initial page together after TYPE.
@@ -3257,15 +3278,15 @@ where
let v: RedisRawValue = redis::cmd("GETRANGE")
.arg(key)
.arg(0)
.arg(STRING_PREVIEW_MAX_BYTES as i64)
.arg(preview_limit_bytes as i64)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
let mut bytes =
redis_value_to_bytes(v).ok_or_else(|| "Redis string payload is not byte-addressable".to_string())?;
let truncated = bytes.len() > STRING_PREVIEW_MAX_BYTES;
let truncated = bytes.len() > preview_limit_bytes;
if truncated {
bytes.truncate(STRING_PREVIEW_MAX_BYTES);
bytes.truncate(preview_limit_bytes);
}
let total_bytes = if truncated {
redis::cmd("STRLEN").arg(key).query_async(con).await.ok()
@@ -3319,9 +3340,9 @@ where
// 且 GET 返回的字节与 Redis 位序一致,可直接按字符串预览。
let raw: RedisRawValue = redis::cmd("GET").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
let mut bytes = redis_value_to_bytes(raw).unwrap_or_default();
let truncated = bytes.len() > STRING_PREVIEW_MAX_BYTES;
let truncated = bytes.len() > preview_limit_bytes;
if truncated {
bytes.truncate(STRING_PREVIEW_MAX_BYTES);
bytes.truncate(preview_limit_bytes);
}
// 截断后拿不到准确长度(kvrocks 位图没有可用的 STRLEN),置空交给前端按未知处理
let total_bytes = (!truncated).then_some(bytes.len() as u64);
@@ -3347,7 +3368,7 @@ where
/// Read the complete raw payload for a Redis string-like key.
///
/// The normal value path deliberately caps previews at 64 KiB. Downloads must
/// The normal value path caps previews at the configured size. Downloads must
/// bypass that cap while preserving the exact bytes, including non-UTF-8 data.
pub async fn get_raw_value<C>(con: &mut C, key: &[u8]) -> Result<RedisBlob, String>
where
@@ -7046,6 +7067,94 @@ mod tests {
assert_eq!(con.command_count("STRLEN"), 1);
}
#[tokio::test]
async fn value_preview_limit_controls_the_range_and_exact_boundary() {
for limit in [1024, 256 * 1024, super::VALUE_PREVIEW_MAX_BYTES] {
// Large limit uses a small payload to avoid allocating 64 MiB in a unit test.
let size = limit.min(256 * 1024);
let mut con = FakeRedisConnection::new(vec![
bulk("string"),
RedisRawValue::Int(-1),
RedisRawValue::BulkString(vec![b'x'; size]),
]);
let value = super::get_value_scoped_with_preview_limit(
&mut con,
b"large",
&super::RedisOperationScope::default(),
limit,
)
.await
.unwrap();
let RedisValueData::String { content, total_bytes, truncated } = value.data else {
panic!("expected string")
};
assert!(!truncated);
assert_eq!(total_bytes, Some(size as u64));
assert_eq!(content.raw_base64, base64::engine::general_purpose::STANDARD.encode(vec![b'x'; size]));
assert!(con.commands.iter().any(|command| command.contains(&format!("\r\n{limit}\r\n"))));
assert_eq!(con.command_count("GET"), 0);
}
let limit = 128 * 1024;
let mut con = FakeRedisConnection::new(vec![
bulk("string"),
RedisRawValue::Int(-1),
RedisRawValue::BulkString(vec![0xff; limit + 1]),
RedisRawValue::Int(200_000),
]);
let value = super::get_value_scoped_with_preview_limit(
&mut con,
b"binary",
&super::RedisOperationScope::default(),
limit,
)
.await
.unwrap();
let RedisValueData::String { content, total_bytes, truncated } = value.data else { panic!("expected string") };
assert!(truncated);
assert_eq!(total_bytes, Some(200_000));
assert_eq!(base64::engine::general_purpose::STANDARD.decode(content.raw_base64).unwrap().len(), limit);
}
#[tokio::test]
async fn value_preview_limit_rejects_invalid_sizes_before_redis_io() {
for limit in [0, 1023, super::VALUE_PREVIEW_MAX_BYTES + 1, usize::MAX] {
let mut con = FakeRedisConnection::new(vec![]);
assert!(super::get_value_scoped_with_preview_limit(
&mut con,
b"key",
&super::RedisOperationScope::default(),
limit,
)
.await
.is_err());
assert!(con.commands.is_empty());
}
}
#[tokio::test]
async fn bitmap_preview_uses_the_configured_limit() {
let mut con = FakeRedisConnection::new(vec![
bulk("bitmap"),
RedisRawValue::Int(-1),
RedisRawValue::BulkString(vec![0x80; 4097]),
RedisRawValue::Int(4097),
]);
let value = super::get_value_scoped_with_preview_limit(
&mut con,
b"bitmap",
&super::RedisOperationScope::default(),
4096,
)
.await
.unwrap();
let RedisValueData::Bitmap { content, total_bytes, truncated, .. } = value.data else {
panic!("expected bitmap")
};
assert!(truncated);
assert_eq!(total_bytes, None);
assert_eq!(base64::engine::general_purpose::STANDARD.decode(content.raw_base64).unwrap().len(), 4096);
}
#[test]
fn collection_pages_stop_at_the_byte_budget_without_truncating_a_member() {
fn hash_items(count: usize, value_bytes: usize) -> Vec<RedisHashItem> {
@@ -0,0 +1,49 @@
use base64::Engine;
use dbx_driver_redis::{
connect, get_raw_value, get_value, get_value_scoped_with_preview_limit, RedisOperationScope, RedisValueData,
};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[tokio::test]
#[ignore = "requires a disposable server via DBX_TEST_REDIS_URL"]
async fn configured_preview_limit_reads_full_large_values_and_preserves_binary_downloads() {
let url = std::env::var("DBX_TEST_REDIS_URL").expect("DBX_TEST_REDIS_URL");
let mut con = connect(&url, Duration::from_secs(5)).await.unwrap();
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let prefix = format!("dbx11326-{}-{nonce}", std::process::id());
let key = prefix.as_bytes();
let scope = RedisOperationScope::default();
for (name, bytes) in [
("text", vec![b'x'; 96 * 1024]),
("utf8", "中文🙂".repeat(10_000).into_bytes()),
("binary", (0..128 * 1024).map(|i| (i % 256) as u8).collect()),
] {
redis::cmd("SET").arg(key).arg(&bytes).arg("EX").arg(300).query_async::<()>(&mut con).await.unwrap();
let default_value = get_value(&mut con, key).await.unwrap();
let RedisValueData::String { content, truncated, total_bytes } = default_value.data else {
panic!("expected string")
};
assert!(truncated);
assert_eq!(total_bytes, Some(bytes.len() as u64));
assert_eq!(base64::engine::general_purpose::STANDARD.decode(content.raw_base64).unwrap(), bytes[..65536]);
for limit in [256 * 1024, bytes.len()] {
let value = get_value_scoped_with_preview_limit(&mut con, key, &scope, limit).await.unwrap();
let RedisValueData::String { content, truncated, total_bytes } = value.data else {
panic!("expected string")
};
assert!(!truncated);
assert_eq!(total_bytes, Some(bytes.len() as u64));
assert_eq!(base64::engine::general_purpose::STANDARD.decode(content.raw_base64).unwrap(), bytes);
}
let small = get_value_scoped_with_preview_limit(&mut con, key, &scope, 1024).await.unwrap();
let RedisValueData::String { truncated, .. } = small.data else { panic!("expected string") };
assert!(truncated);
let raw = get_raw_value(&mut con, key).await.unwrap();
assert_eq!(base64::engine::general_purpose::STANDARD.decode(raw.raw_base64).unwrap(), bytes);
println!(
"PASS {name}: 64 KiB truncates, 256 KiB and exact boundary complete, full download preserves {} bytes",
bytes.len()
);
}
redis::cmd("DEL").arg(key).query_async::<()>(&mut con).await.unwrap();
}
+17 -2
View File
@@ -77,6 +77,15 @@ pub struct RedisKeyRequest {
pub key_raw: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisValueRequest {
pub connection_id: String,
pub db: u32,
pub key_raw: String,
pub preview_limit_bytes: Option<usize>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisRenameKeyRequest {
@@ -404,9 +413,15 @@ pub async fn scan_values(
pub async fn get_value(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisKeyRequest>,
Json(req): Json<RedisValueRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = dbx_core::redis_ops::redis_get_value_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw)
let result = dbx_core::redis_ops::redis_get_value_in_db_with_preview_limit_core(
&state.app,
&req.connection_id,
req.db,
&req.key_raw,
req.preview_limit_bytes,
)
.await
.map_err(AppError::from)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+38
View File
@@ -0,0 +1,38 @@
# Configurable Redis value preview limit (#11326)
Verified on macOS 26.3 with the local DBX Web build on 2026-10-14.
The new setting is at the bottom of **Redis connection → Advanced settings**. It is stored globally and applies to every Redis connection. It defaults to 64 KiB and
accepts 1–65536 KiB. The selected value is passed to the Redis String/Bitmap
preview request; complete downloads still use the raw-value endpoint.
![Redis preview setting set to 256 KiB](assets/redis-value-preview-11326/setting-256-kib.png)
## Manual Web verification
A local Redis 7.4.9 server was seeded with these values:
- a 101,084 byte JSON document containing 850 items and a final `END-11326` marker;
- a 131,072 byte binary value containing every byte from 0 through 255 repeatedly;
- a 5 byte value;
- a 65,536 byte boundary value.
With the default 64 KiB setting, the 101,084 byte JSON appeared as a read-only
preview with the message that only 64 KiB was loaded. After changing the setting
to 256 KiB and applying it, the open detail automatically refreshed: the full
JSON became editable, the truncation notice disappeared, and the final items
and `END-11326` marker were visible. Reloading the Web app preserved 256 KiB and
opening the same key again kept the full editable value.
![Full value after reload with 256 KiB persisted](assets/redis-value-preview-11326/persisted-full-value.png)
## Automated verification
- 187 `dbx-driver-redis` unit tests passed.
- The ignored integration test against the local Redis server passed for text,
UTF-8 Chinese content, and binary data: 64 KiB truncation, 256 KiB complete
reads, exact-boundary reads, and complete raw downloads all preserved bytes.
- 470 frontend tests passed across 23 focused suites, including Redis viewer behavior, settings apply and reset, settings transfer validation, and HTTP/Tauri argument forwarding.
- Frontend formatting, lint, typecheck, and the Web backend build passed.
- Invalid preview sizes below 1 KiB and above 64 MiB were rejected before Redis
I/O.
+9 -1
View File
@@ -83,8 +83,16 @@ pub async fn redis_get_value(
connection_id: String,
db: u32,
key_raw: String,
preview_limit_bytes: Option<usize>,
) -> Result<RedisValue, String> {
dbx_core::redis_ops::redis_get_value_in_db_core(&state, &connection_id, db, &key_raw).await
dbx_core::redis_ops::redis_get_value_in_db_with_preview_limit_core(
&state,
&connection_id,
db,
&key_raw,
preview_limit_bytes,
)
.await
}
#[tauri::command]