feat(redis): support score sorting and inline sorted-set editing

This commit is contained in:
二丫讲梵 2026-08-05 09:46:18 +08:00 committed by GitHub
parent 2c91cdaba3
commit ffaede31b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 621 additions and 102 deletions

View File

@ -37,6 +37,11 @@ vi.mock("@/lib/common/shikiJsonHighlighter", () => ({
createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""),
}));
vi.mock("@/lib/redis/redisCompression", () => ({
decompressRedisValue: vi.fn().mockResolvedValue({ ok: false, reason: "corrupt" }),
isGzipMagic: vi.fn().mockReturnValue(false),
}));
vi.mock("vue-virtual-scroller", async () => {
const { defineComponent, h } = await import("vue");
const DynamicScroller = defineComponent({
@ -51,8 +56,13 @@ vi.mock("vue-virtual-scroller", async () => {
},
});
const RecycleScroller = defineComponent({
setup(_, { slots }) {
return () => h("div", slots.default?.());
props: { items: { type: Array, default: () => [] } },
setup(props, { slots }) {
return () =>
h(
"div",
(props.items as unknown[]).flatMap((item, index) => slots.default?.({ item, index }) ?? []),
);
},
});
@ -96,8 +106,22 @@ function streamValue(entries = [] as ReturnType<typeof streamEntry>[], nextCurso
};
}
function blob(raw_base64: string) {
return { raw_base64, encoding: "utf8" as const };
function blob(raw_base64: string, encoding: "utf8" | "binary" = "utf8") {
return { raw_base64, encoding };
}
function zsetValue() {
return {
key_display: "scores",
key_raw: "c2NvcmVz",
ttl: -1,
redis_type: "zset",
data: {
kind: "zset" as const,
items: [{ score: "1", member: blob("AP8=", "binary") }],
total: 1,
},
};
}
function deferred<T>() {
@ -443,3 +467,21 @@ describe("RedisValueViewer stream monitoring", () => {
expect(host.querySelector("[data-redis-stream-pending]")?.textContent).toContain("1714470000000-0");
});
});
describe("RedisValueViewer ZSet member details", () => {
it("opens the full detail dialog for a binary ZSet member", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
const host = mountViewer();
await settle();
const viewMember = host.querySelector<HTMLButtonElement>("[data-redis-zset-view-member]");
expect(viewMember).not.toBeNull();
viewMember!.click();
await settle();
const detail = document.querySelector<HTMLElement>("[data-redis-member-detail]");
expect(detail).not.toBeNull();
expect(detail!.textContent).toContain("Base64");
});
});

View File

@ -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, FileArchive } from "@lucide/vue";
import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, X, FileArchive } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@ -156,6 +156,10 @@ const selectedMemberContext = ref<RedisMemberContext | null>(null);
const isEditingMember = ref(false);
const savingMember = ref(false);
const memberEditValue = ref("");
const editingZsetMemberKey = ref<string | null>(null);
const savingZsetMember = ref(false);
const zsetInlineMember = ref("");
const zsetInlineScore = ref("");
const hashTableRef = ref<HTMLElement | null>(null);
const hashFieldWidth = ref(280);
const isResizingHashColumns = ref(false);
@ -358,6 +362,7 @@ function handleDocumentVisibilityChange() {
const hashSortBy = ref<"field" | "value" | null>(null);
const hashSortDir = ref<"asc" | "desc">("asc");
const zsetSortDir = ref<"asc" | "desc">("asc");
/**
* In-content find (Ctrl+F) for:
* - Redis STRING keys
@ -391,6 +396,18 @@ function toggleHashSort(column: "field" | "value") {
}
}
async function toggleZsetSort() {
if (redisKind.value !== "zset" || loading.value || loadingMore.value || editingZsetMemberKey.value !== null) return;
const previousDirection = zsetSortDir.value;
zsetSortDir.value = previousDirection === "asc" ? "desc" : "asc";
try {
await load({ notifyParent: false });
} catch (error) {
zsetSortDir.value = previousDirection;
toast(errorMessage(error), 3000);
}
}
const redisKind = computed(() => data.value?.data.kind ?? "unknown");
const isStringLikeKind = computed(() => redisKind.value === "string");
const stringBlob = computed<RedisBlob | null>(() => {
@ -480,14 +497,14 @@ const hasRetainedMemberDraft = computed(() => {
}
return memberEditValue.value !== original;
});
const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value);
const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null);
const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0);
const collectionTotal = computed(() => (data.value ? redisValueCollectionTotal(data.value) : null));
const hashGridStyle = computed(() => ({
gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`,
}));
const zsetGridStyle = computed(() => ({
gridTemplateColumns: `${zsetScoreWidth.value}px minmax(0, 1fr) 84px`,
gridTemplateColumns: `60px ${zsetScoreWidth.value}px minmax(0, 1fr) 104px`,
}));
const metadataSizeLabel = computed(() => {
const metadata = props.metadata;
@ -548,14 +565,33 @@ const setRows = computed<RedisCollectionRow<RedisSetItem>[]>(() =>
}))
: [],
);
function zsetScoreValue(score: string): number {
const normalized = score.toLowerCase();
if (normalized === "-inf") return -Infinity;
if (normalized === "inf" || normalized === "+inf") return Infinity;
return Number(score);
}
function isValidZsetScore(score: string): boolean {
const normalized = score.toLowerCase();
return normalized === "-inf" || normalized === "inf" || normalized === "+inf" || Number.isFinite(Number(score));
}
const sortedZsetItems = computed<RedisZsetItem[]>(() => {
if (redisKind.value !== "zset") return [];
const multiplier = zsetSortDir.value === "asc" ? 1 : -1;
return [...(collectionItems.value as RedisZsetItem[])].sort((a, b) => {
const difference = zsetScoreValue(a.score) - zsetScoreValue(b.score);
return Number.isNaN(difference) ? a.score.localeCompare(b.score) * multiplier : difference * multiplier;
});
});
const zsetRows = computed<RedisCollectionRow<RedisZsetItem>[]>(() =>
redisKind.value === "zset"
? (collectionItems.value as RedisZsetItem[]).map((value, index) => ({
id: collectionRowId(value, index),
index,
value,
}))
: [],
sortedZsetItems.value.map((value, index) => ({
id: collectionRowId(value, index),
index,
value,
})),
);
const usesJsonEditorForMain = computed(() => (isStringLikeKind.value && stringValueView.value === "json" && Boolean(stringValueDetail.value?.json)) || redisKind.value === "json");
@ -607,7 +643,7 @@ let zsetResizeStartWidth = 0;
function shouldPauseAutoValueRefresh(): boolean {
const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0;
const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize;
return showMemberDetail.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
return showMemberDetail.value || editingZsetMemberKey.value !== null || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
}
type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string };
@ -1168,7 +1204,20 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
const requestId = ++loadRequestId;
if (!background) loading.value = true;
try {
const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
let loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
if (loadedValue.data.kind === "zset" && zsetSortDir.value === "desc") {
const sortedPage = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "zset", 0, 200, undefined, "desc");
if (sortedPage.kind === "zset") {
loadedValue = {
...loadedValue,
data: {
...loadedValue.data,
items: sortedPage.items,
scan_cursor: sortedPage.scan_cursor,
},
};
}
}
if (requestId !== loadRequestId || (options.shouldApply && !options.shouldApply())) return false;
// Redis reports a key that expired between refreshes as a `none` value.
@ -1178,6 +1227,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
// the draft and defer even a missing-key update until the user decides
// whether to save or discard it.
if (background && options.preserveDraft && hasUnsavedRedisDraft.value) return false;
resetZsetInlineEdit();
data.value = null;
collectionItems.value = [];
scanCursor.value = undefined;
@ -1211,6 +1261,9 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
if (notifyParent) emit("loaded", loadedValue);
scanCursor.value = redisValueCollectionScanCursor(loadedValue);
collectionItems.value = redisValueCollectionItems(loadedValue);
if (editingZsetMemberKey.value !== null && (loadedValue.data.kind !== "zset" || !loadedValue.data.items.some((item) => zsetInlineEditKey(item) === editingZsetMemberKey.value))) {
resetZsetInlineEdit();
}
replaceStreamEntries(loadedValue);
if (loadedValue.data.kind !== "stream") resetStreamMonitoring();
@ -1260,7 +1313,8 @@ async function loadMore() {
const requestId = hashSearchRequestId;
loadingMore.value = true;
try {
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter);
const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined;
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter, sortDirection);
if (keyType === "hash" && requestId !== hashSearchRequestId) return;
const newItems = redisCollectionPageItems(result);
collectionItems.value = [...collectionItems.value, ...newItems];
@ -1515,6 +1569,7 @@ function stopResizeHashColumns() {
isResizingHashColumns.value = false;
window.removeEventListener("pointermove", resizeHashColumns);
window.removeEventListener("pointerup", stopResizeHashColumns);
window.removeEventListener("pointercancel", stopResizeHashColumns);
}
function resizeHashColumns(event: PointerEvent) {
@ -1529,12 +1584,13 @@ function startResizeHashColumns(event: PointerEvent) {
hashResizeStartWidth = hashFieldWidth.value;
window.addEventListener("pointermove", resizeHashColumns);
window.addEventListener("pointerup", stopResizeHashColumns);
window.addEventListener("pointercancel", stopResizeHashColumns);
}
function clampZsetScoreWidth(width: number) {
const containerWidth = zsetTableRef.value?.clientWidth ?? 900;
const min = 120;
const max = Math.max(min, containerWidth - 220);
const max = Math.max(min, containerWidth - 300);
return Math.min(max, Math.max(min, width));
}
@ -1542,6 +1598,7 @@ function stopResizeZsetColumns() {
isResizingZsetColumns.value = false;
window.removeEventListener("pointermove", resizeZsetColumns);
window.removeEventListener("pointerup", stopResizeZsetColumns);
window.removeEventListener("pointercancel", stopResizeZsetColumns);
}
function resizeZsetColumns(event: PointerEvent) {
@ -1556,6 +1613,68 @@ function startResizeZsetColumns(event: PointerEvent) {
zsetResizeStartWidth = zsetScoreWidth.value;
window.addEventListener("pointermove", resizeZsetColumns);
window.addEventListener("pointerup", stopResizeZsetColumns);
window.addEventListener("pointercancel", stopResizeZsetColumns);
}
function zsetInlineEditKey(item: RedisZsetItem): string {
return item.member.raw_base64;
}
function isEditingZsetRow(item: RedisZsetItem): boolean {
return editingZsetMemberKey.value === zsetInlineEditKey(item);
}
function startZsetInlineEdit(item: RedisZsetItem) {
const member = redisBlobText(item.member);
if (member == null || savingZsetMember.value) return;
editingZsetMemberKey.value = zsetInlineEditKey(item);
zsetInlineMember.value = member;
zsetInlineScore.value = item.score;
}
function resetZsetInlineEdit() {
editingZsetMemberKey.value = null;
zsetInlineMember.value = "";
zsetInlineScore.value = "";
}
function cancelZsetInlineEdit() {
if (savingZsetMember.value) return;
resetZsetInlineEdit();
}
async function saveZsetInlineEdit(item: RedisZsetItem) {
const originalMember = redisBlobText(item.member);
const scoreText = zsetInlineScore.value.trim();
if (originalMember == null || savingZsetMember.value) return;
if (!scoreText || !isValidZsetScore(scoreText)) {
toast(t("redis.createScoreInvalid"), 3000);
return;
}
if (!zsetInlineMember.value.trim()) {
toast(t("redis.memberRequired"), 3000);
return;
}
savingZsetMember.value = true;
try {
try {
const usedAclCompatibility = await api.redisZsetUpdate(props.connectionId, props.db, props.keyRaw, originalMember, item.score, zsetInlineMember.value, scoreText);
if (usedAclCompatibility) toast(t("redis.zsetAclCompatibilityWarning"), 5000);
} catch (error) {
toast(errorMessage(error), 3000);
return;
}
resetZsetInlineEdit();
try {
await load();
} catch (error) {
toast(t("redis.updateAppliedRefreshFailed", { message: errorMessage(error) }), 5000);
}
} finally {
savingZsetMember.value = false;
}
}
function startEditMember() {
@ -1608,8 +1727,13 @@ async function saveMemberEdit() {
nextContext = { kind: "set", member: writeValue, canEdit: true };
} else if (context.kind === "zset") {
if (!context.member) return;
await api.redisZrem(props.connectionId, props.db, props.keyRaw, context.member);
await api.redisZadd(props.connectionId, props.db, props.keyRaw, writeValue, Number(context.score));
try {
const usedAclCompatibility = await api.redisZsetUpdate(props.connectionId, props.db, props.keyRaw, context.member, context.score, writeValue, context.score);
if (usedAclCompatibility) toast(t("redis.zsetAclCompatibilityWarning"), 5000);
} catch (error) {
toast(errorMessage(error), 3000);
return;
}
nextContext = { kind: "zset", member: writeValue, score: context.score, canEdit: true };
}
const editedValue = writeValue;
@ -2445,17 +2569,14 @@ defineExpose({ focusSearch });
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="hashSet"><Plus class="w-3 h-3 mr-1" />Set</Button>
</div>
<div class="grid border-b bg-muted/50 shrink-0" :style="hashGridStyle">
<div
class="relative px-3 py-1 text-xs font-medium text-muted-foreground border-r select-none cursor-pointer hover:bg-accent/50 flex items-center gap-1"
role="columnheader"
:aria-sort="hashSortBy === 'field' ? (hashSortDir === 'asc' ? 'ascending' : 'descending') : 'none'"
@click="toggleHashSort('field')"
>
Field
<ArrowUp v-if="hashSortBy === 'field' && hashSortDir === 'asc'" class="h-3 w-3 shrink-0" />
<ArrowDown v-else-if="hashSortBy === 'field' && hashSortDir === 'desc'" class="h-3 w-3 shrink-0" />
<ArrowUpDown v-else class="h-3 w-3 shrink-0 text-muted-foreground/40" />
<div class="absolute -right-1 top-0 h-full w-2 cursor-col-resize touch-none" @pointerdown.prevent="startResizeHashColumns" />
<div class="relative border-r text-xs font-medium text-muted-foreground select-none" role="columnheader" :aria-sort="hashSortBy === 'field' ? (hashSortDir === 'asc' ? 'ascending' : 'descending') : 'none'">
<button type="button" class="flex h-full w-full cursor-pointer items-center gap-1 px-3 py-1 text-left hover:bg-accent/50" @click="toggleHashSort('field')">
Field
<ArrowUp v-if="hashSortBy === 'field' && hashSortDir === 'asc'" class="h-3 w-3 shrink-0" />
<ArrowDown v-else-if="hashSortBy === 'field' && hashSortDir === 'desc'" class="h-3 w-3 shrink-0" />
<ArrowUpDown v-else class="h-3 w-3 shrink-0 text-muted-foreground/40" />
</button>
<div class="absolute -right-1 top-0 h-full w-2 cursor-col-resize touch-none" @pointerdown.stop.prevent="startResizeHashColumns" />
</div>
<div class="px-3 py-1 text-xs font-medium text-muted-foreground cursor-pointer hover:bg-accent/50 flex items-center gap-1 select-none" role="columnheader" :aria-sort="hashSortBy === 'value' ? (hashSortDir === 'asc' ? 'ascending' : 'descending') : 'none'" @click="toggleHashSort('value')">
Value
@ -2511,39 +2632,57 @@ defineExpose({ focusSearch });
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="zsetAdd"><Plus class="w-3 h-3 mr-1" />Add</Button>
</div>
<div class="grid border-b bg-muted/50 shrink-0" :style="zsetGridStyle">
<div class="relative px-3 py-1 text-xs font-medium text-muted-foreground border-r select-none">
Score
<div class="absolute -right-1 top-0 h-full w-2 cursor-col-resize touch-none" @pointerdown.prevent="startResizeZsetColumns" />
<div class="px-3 py-1 text-center text-xs font-medium text-muted-foreground border-r" role="columnheader">#</div>
<div class="relative border-r text-xs font-medium text-muted-foreground select-none" role="columnheader" :aria-sort="zsetSortDir === 'asc' ? 'ascending' : 'descending'">
<button type="button" class="flex h-full w-full cursor-pointer items-center gap-1 px-3 py-1 text-left hover:bg-accent/50" @click="toggleZsetSort">
Score
<ArrowUp v-if="zsetSortDir === 'asc'" class="h-3 w-3 shrink-0" />
<ArrowDown v-else class="h-3 w-3 shrink-0" />
</button>
<div class="absolute -right-1 top-0 h-full w-2 cursor-col-resize touch-none" @pointerdown.stop.prevent="startResizeZsetColumns" />
</div>
<div class="px-3 py-1 text-xs font-medium text-muted-foreground min-w-0">Member</div>
<div />
</div>
<RecycleScroller class="flex-1 overflow-y-auto" :items="zsetRows" :item-size="REDIS_COLLECTION_ROW_HEIGHT" :buffer="600" :skip-hover="true" key-field="id">
<template #default="{ item: row }">
<div
data-redis-value-row
class="dbx-editor-font-family grid border-b text-sm hover:bg-accent/50 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(String(row.value.score), row.value.member) }"
:style="{ ...zsetGridStyle, height: `${REDIS_COLLECTION_ROW_HEIGHT}px` }"
@click="viewMember(row.value.score, row.value.member, { kind: 'zset', member: redisBlobText(row.value.member), score: row.value.score, canEdit: redisBlobText(row.value.member) != null && canEditRedisMemberDetail('zset', row.value.member) })"
>
<div class="px-3 py-1.5 text-muted-foreground text-xs border-r min-w-0 truncate" :title="String(row.value.score)">
{{ row.value.score }}
<div data-redis-value-row class="dbx-editor-font-family grid border-b text-sm hover:bg-accent/50 group" :class="{ 'bg-accent/60': isEditingZsetRow(row.value) }" :style="{ ...zsetGridStyle, height: `${REDIS_COLLECTION_ROW_HEIGHT}px` }">
<div class="px-3 py-1.5 text-center text-xs text-muted-foreground border-r tabular-nums">{{ row.index + 1 }}</div>
<div class="flex min-w-0 items-center border-r px-3 py-1.5 text-xs text-muted-foreground">
<Input v-if="isEditingZsetRow(row.value)" v-model="zsetInlineScore" aria-label="Score" class="h-6 min-w-0 text-xs tabular-nums" :disabled="savingZsetMember" inputmode="decimal" @keydown.enter.prevent="saveZsetInlineEdit(row.value)" />
<span v-else class="min-w-0 truncate" :title="String(row.value.score)">{{ row.value.score }}</span>
</div>
<div class="px-3 py-1.5 min-w-0 truncate" :title="formatValue(row.value.member)">
{{ formatValue(row.value.member) }}
<div class="flex min-w-0 items-center px-3 py-1.5">
<Input v-if="isEditingZsetRow(row.value)" v-model="zsetInlineMember" aria-label="Member" class="dbx-editor-font-family h-6 min-w-0 text-sm" :disabled="savingZsetMember" @keydown.enter.prevent="saveZsetInlineEdit(row.value)" />
<span v-else class="min-w-0 truncate" :title="formatValue(row.value.member)">{{ formatValue(row.value.member) }}</span>
</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(row.value.score, row.value.member, { kind: 'zset', member: redisBlobText(row.value.member), score: row.value.score, canEdit: redisBlobText(row.value.member) != null && canEditRedisMemberDetail('zset', row.value.member) })"
><Eye class="w-3 h-3"
/></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(row.value.member)"><Copy class="w-3 h-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" :disabled="!canDeleteZsetItem(row.value)" @click.stop="requestZsetRemove(redisBlobText(row.value.member))"><Trash2 class="w-3 h-3" /></Button>
<template v-if="isEditingZsetRow(row.value)">
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="savingZsetMember" :title="t('grid.save')" @click="saveZsetInlineEdit(row.value)"><Loader2 v-if="savingZsetMember" class="h-3 w-3 animate-spin" /><Save v-else class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="savingZsetMember" :title="t('grid.discard')" @click="cancelZsetInlineEdit"><X class="h-3 w-3" /></Button>
</template>
<template v-else>
<Button
data-redis-zset-view-member
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(row.value.score, row.value.member, { kind: 'zset', member: redisBlobText(row.value.member), score: row.value.score, canEdit: redisBlobText(row.value.member) != null && canEditRedisMemberDetail('zset', row.value.member) })"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:disabled="editingZsetMemberKey !== null || redisBlobText(row.value.member) == null || !canEditRedisMemberDetail('zset', row.value.member)"
:title="t('redis.editMember')"
@click="startZsetInlineEdit(row.value)"
><Pencil class="w-3 h-3"
/></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click="copyMember(row.value.member)"><Copy class="w-3 h-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" :disabled="!canDeleteZsetItem(row.value)" @click="requestZsetRemove(redisBlobText(row.value.member))"><Trash2 class="w-3 h-3" /></Button>
</template>
</div>
</div>
</template>

View File

@ -3662,6 +3662,8 @@ export default {
fieldRequired: "Enter a field name",
valueRequired: "Enter a value",
memberRequired: "Enter a member",
updateAppliedRefreshFailed: "The change was saved, but the value could not be refreshed: {message}",
zsetAclCompatibilityWarning: "The change was saved using ACL compatibility mode; concurrent score changes could not be checked.",
ttlInvalid: "Enter a valid TTL in seconds (-1 for no expiry)",
member: "Member",
memberDetail: "Member detail",

View File

@ -3513,6 +3513,8 @@ export default withEnglishFallback({
fieldRequired: "Ingresa un nombre de campo",
valueRequired: "Ingresa un valor",
memberRequired: "Ingresa un miembro",
updateAppliedRefreshFailed: "El cambio se guardó, pero no se pudo actualizar el valor: {message}",
zsetAclCompatibilityWarning: "El cambio se guardó en modo de compatibilidad ACL; no se pudieron comprobar cambios simultáneos en la puntuación.",
ttlInvalid: "Ingresa un TTL válido en segundos (-1 para sin caducidad)",
member: "Miembro",
memberDetail: "Detalle del miembro",

View File

@ -3511,6 +3511,8 @@ export default withEnglishFallback({
fieldRequired: "Inserisci un nome campo",
valueRequired: "Inserisci un valore",
memberRequired: "Inserisci un membro",
updateAppliedRefreshFailed: "La modifica è stata salvata, ma non è stato possibile aggiornare il valore: {message}",
zsetAclCompatibilityWarning: "La modifica è stata salvata in modalità compatibilità ACL; non è stato possibile verificare modifiche concorrenti al punteggio.",
ttlInvalid: "Inserisci un TTL valido in secondi (-1 per nessuna scadenza)",
member: "Membro",
memberDetail: "Dettaglio membro",

View File

@ -3543,6 +3543,8 @@ export default withEnglishFallback({
fieldRequired: "フィールド名を入力してください",
valueRequired: "値を入力してください",
memberRequired: "メンバーを入力してください",
updateAppliedRefreshFailed: "変更は保存されましたが、値を更新できませんでした: {message}",
zsetAclCompatibilityWarning: "ACL 互換モードで保存しました。同時に行われたスコア変更は確認できませんでした。",
ttlInvalid: "有効なTTLを秒で入力してください-1で期限なし",
member: "メンバー",
memberDetail: "メンバー詳細",

View File

@ -3210,6 +3210,8 @@ export default withEnglishFallback({
fieldRequired: "필드 이름을 입력하세요",
valueRequired: "값을 입력하세요",
memberRequired: "멤버를 입력하세요",
updateAppliedRefreshFailed: "변경 사항은 저장되었지만 값을 새로 고치지 못했습니다: {message}",
zsetAclCompatibilityWarning: "ACL 호환 모드로 저장했습니다. 동시에 변경된 점수는 확인할 수 없습니다.",
ttlInvalid: "초 단위의 올바른 TTL을 입력하세요 (만료 없음은 -1)",
member: "멤버",
memberDetail: "멤버 상세",

View File

@ -3513,6 +3513,8 @@ export default withEnglishFallback({
fieldRequired: "Insira um nome de campo",
valueRequired: "Insira um valor",
memberRequired: "Insira um membro",
updateAppliedRefreshFailed: "A alteração foi salva, mas não foi possível atualizar o valor: {message}",
zsetAclCompatibilityWarning: "A alteração foi salva no modo de compatibilidade ACL; não foi possível verificar alterações simultâneas na pontuação.",
ttlInvalid: "Insira um TTL válido em segundos (-1 para sem expiração)",
member: "Membro",
memberDetail: "Detalhe do membro",

View File

@ -3662,6 +3662,8 @@ export default withEnglishFallback({
fieldRequired: "请输入字段名",
valueRequired: "请输入值",
memberRequired: "请输入成员",
updateAppliedRefreshFailed: "修改已保存,但刷新值失败:{message}",
zsetAclCompatibilityWarning: "修改已通过 ACL 兼容模式保存,无法校验并发的分数变更。",
ttlInvalid: "请输入有效的 TTL秒数-1 表示永不过期)",
member: "成员",
memberDetail: "成员详情",

View File

@ -2981,6 +2981,8 @@ export default withEnglishFallback({
fieldRequired: "請輸入欄位名稱",
valueRequired: "請輸入值",
memberRequired: "請輸入成員",
updateAppliedRefreshFailed: "變更已儲存,但重新整理值失敗:{message}",
zsetAclCompatibilityWarning: "變更已透過 ACL 相容模式儲存,無法驗證並行的分數變更。",
ttlInvalid: "請輸入有效的 TTL秒數-1 表示永不過期)",
member: "成員",
memberDetail: "成員詳情",

View File

@ -260,4 +260,12 @@ describe("native RedisJSON editor", () => {
expect(directiveExpression(memberTextarea, "bind", "readonly")).toBe("savingMember");
expect(directiveExpression(refreshButton, "bind", "disabled")).toBe("loading || refreshingValue || hasUnsavedRedisDraft");
});
it("passes the loaded ZSet score into both update entry points", () => {
const inlineSave = findFunction("saveZsetInlineEdit").getText();
const detailSave = findFunction("saveMemberEdit").getText();
expect(inlineSave).toContain("originalMember, item.score, zsetInlineMember.value, scoreText");
expect(detailSave).toContain("context.member, context.score, writeValue, context.score");
});
});

View File

@ -413,6 +413,7 @@ export const redisSetAdd = forward("redisSetAdd");
export const redisSetRemove = forward("redisSetRemove");
export const redisZadd = forward("redisZadd");
export const redisZrem = forward("redisZrem");
export const redisZsetUpdate = forward("redisZsetUpdate");
export const redisStreamAdd = forward("redisStreamAdd");
export const redisJsonSet = forward("redisJsonSet");
export const redisCheckJsonModule = forward("redisCheckJsonModule");

View File

@ -2416,6 +2416,10 @@ export async function redisZrem(connectionId: string, db: number, keyRaw: string
return post("/api/redis/zrem", { connectionId, db, keyRaw, member });
}
export async function redisZsetUpdate(connectionId: string, db: number, keyRaw: string, originalMember: string, expectedScore: string, member: string, score: string): Promise<boolean> {
return post("/api/redis/zset-update", { connectionId, db, keyRaw, originalMember, expectedScore, member, score });
}
export async function redisStreamAdd(connectionId: string, db: number, keyRaw: string, entryId: string, fields: [string, string][], ttl?: number): Promise<void> {
return post("/api/redis/stream-add", {
connectionId,
@ -2465,7 +2469,7 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm
});
}
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisCollectionPage> {
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string, sortDirection?: "asc" | "desc"): Promise<RedisCollectionPage> {
return post("/api/redis/load-more", {
connectionId,
db,
@ -2474,6 +2478,7 @@ export async function redisLoadMore(connectionId: string, db: number, keyRaw: st
cursor,
count,
filter,
sortDirection,
});
}

View File

@ -2231,6 +2231,10 @@ export async function redisZrem(connectionId: string, db: number, keyRaw: string
return invoke("redis_zrem", { connectionId, db, keyRaw, member });
}
export async function redisZsetUpdate(connectionId: string, db: number, keyRaw: string, originalMember: string, expectedScore: string, member: string, score: string): Promise<boolean> {
return invoke("redis_zset_update", { connectionId, db, keyRaw, originalMember, expectedScore, member, score });
}
export async function redisStreamAdd(connectionId: string, db: number, keyRaw: string, entryId: string, fields: [string, string][], ttl?: number): Promise<void> {
return invoke("redis_stream_add", {
connectionId,
@ -2275,7 +2279,7 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm
});
}
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise<RedisCollectionPage> {
export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string, sortDirection?: "asc" | "desc"): Promise<RedisCollectionPage> {
return invoke("redis_load_more", {
connectionId,
db,
@ -2284,6 +2288,7 @@ export async function redisLoadMore(connectionId: string, db: number, keyRaw: st
cursor,
count,
filter,
sortDirection,
});
}

View File

@ -2329,8 +2329,10 @@ where
}
"zset" => {
let len: u64 = redis::cmd("ZCARD").arg(key).query_async(con).await.unwrap_or(0);
let (cursor, items) = zscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE).await?;
RedisValueData::Zset { items, total: len, scan_cursor: (cursor > 0).then_some(cursor) }
let end = (COLLECTION_PAGE_SIZE as i64) - 1;
let items = zrange_page_raw(con, key, 0, end, false).await?;
let cursor = if len > COLLECTION_PAGE_SIZE as u64 { Some(COLLECTION_PAGE_SIZE as u64) } else { None };
RedisValueData::Zset { items, total: len, scan_cursor: cursor }
}
"hash" => {
let len: u64 = redis::cmd("HLEN").arg(key).query_async(con).await.unwrap_or(0);
@ -2927,6 +2929,77 @@ where
redis::cmd("ZREM").arg(key).arg(member).query_async::<()>(con).await.map_err(|e| e.to_string())
}
pub async fn zset_update<C>(
con: &mut C,
key: &[u8],
original_member: &str,
expected_score: &str,
member: &str,
score: &str,
) -> Result<bool, String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
// Keep the source check, duplicate check, score write, and optional rename
// in one Redis operation so a failed save cannot delete the original member.
const SCRIPT: &str = r#"
local current_score = redis.call('ZSCORE', KEYS[1], ARGV[1])
if current_score == false then
return 0
end
if current_score ~= ARGV[3] then
return -2
end
if ARGV[1] ~= ARGV[2] and redis.call('ZSCORE', KEYS[1], ARGV[2]) ~= false then
return -1
end
redis.call('ZADD', KEYS[1], ARGV[4], ARGV[2])
if ARGV[1] ~= ARGV[2] then
redis.call('ZREM', KEYS[1], ARGV[1])
end
return 1
"#;
let result = redis::cmd("EVAL")
.arg(SCRIPT)
.arg(1)
.arg(key)
.arg(original_member)
.arg(member)
.arg(expected_score)
.arg(score)
.query_async::<i64>(con)
.await;
let result = match result {
Ok(result) => result,
Err(error) if is_zset_update_acl_compatibility_error(&error) => {
return Err(
"Atomic ZSet updates require the Redis EVAL and ZSCORE permissions. Ask an administrator to grant them."
.to_string(),
);
}
Err(error) => return Err(error.to_string()),
};
match result {
1 => Ok(false),
0 => Err("The ZSet member no longer exists. Refresh and try again.".to_string()),
-1 => Err("A ZSet member with the new value already exists.".to_string()),
-2 => Err("The ZSet score changed after it was loaded. Refresh and try again.".to_string()),
_ => Err("Unexpected result while updating ZSet member.".to_string()),
}
}
fn is_zset_update_acl_compatibility_error(error: &redis::RedisError) -> bool {
if error.code() != Some("NOPERM") {
return false;
}
let detail = error.detail().unwrap_or_default().to_ascii_lowercase();
detail.contains("eval") || detail.contains("zscore")
}
pub async fn stream_add<C>(
con: &mut C,
key: &[u8],
@ -3023,6 +3096,7 @@ pub async fn load_more_collection<C>(
cursor: u64,
count: usize,
filter_query: Option<&str>,
sort_direction: Option<&str>,
) -> Result<RedisCollectionPage, String>
where
C: ConnectionLike + Send + Sync + Unpin,
@ -3045,8 +3119,18 @@ where
Ok(RedisCollectionPage::Set { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) })
}
"zset" => {
let (next_cursor, items) = zscan_page_raw(con, key, cursor, count).await?;
Ok(RedisCollectionPage::Zset { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) })
let descending = match sort_direction.unwrap_or("asc") {
"asc" => false,
"desc" => true,
direction => return Err(format!("Invalid ZSet sort direction: {direction}")),
};
let start = cursor as i64;
let end = start + count as i64;
let mut items = zrange_page_raw(con, key, start, end, descending).await?;
let has_more = items.len() > count;
items.truncate(count);
let next = cursor + count as u64;
Ok(RedisCollectionPage::Zset { items, scan_cursor: has_more.then_some(next) })
}
"hash" => {
let (next_cursor, items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) {
@ -3137,56 +3221,42 @@ where
parse_scan_members(raw)
}
async fn zscan_page_raw<C>(
async fn zrange_page_raw<C>(
con: &mut C,
key: &[u8],
cursor: u64,
count: usize,
) -> Result<(u64, Vec<RedisZsetItem>), String>
start: i64,
end: i64,
descending: bool,
) -> Result<Vec<RedisZsetItem>, String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let raw: RedisRawValue = redis::cmd("ZSCAN")
let command = if descending { "ZREVRANGE" } else { "ZRANGE" };
let raw: RedisRawValue = redis::cmd(command)
.arg(key)
.arg(cursor)
.arg("COUNT")
.arg(count)
.arg(start)
.arg(end)
.arg("WITHSCORES")
.query_async(con)
.await
.map_err(|e| e.to_string())?;
let (next_cursor, items) = parse_scan_pairs(raw)?;
Ok((next_cursor, items.into_iter().map(|(member, score)| RedisZsetItem { score, member }).collect()))
}
fn parse_scan_pairs(raw: RedisRawValue) -> Result<(u64, Vec<(RedisBlob, String)>), String> {
let RedisRawValue::Array(parts) = raw else {
return Err("Invalid SCAN response".to_string());
let RedisRawValue::Array(entries) = raw else {
return Err("Invalid ZRANGE response".to_string());
};
if parts.len() != 2 {
return Err("Invalid SCAN response".to_string());
if entries.len() % 2 != 0 {
return Err("Invalid ZRANGE response".to_string());
}
let cursor = redis_value_to_string(parts[0].clone())
.ok_or("Invalid cursor")?
.parse::<u64>()
.map_err(|_| "Invalid cursor".to_string())?;
let RedisRawValue::Array(entries) = &parts[1] else {
return Err("Invalid SCAN entries".to_string());
};
let mut items = Vec::new();
let mut iter = entries.iter();
while let Some(a) = iter.next() {
let Some(b) = iter.next() else { break };
let member = redis_value_to_bytes(a.clone())
let mut items = Vec::with_capacity(entries.len() / 2);
for pair in entries.chunks_exact(2) {
let member = redis_value_to_bytes(pair[0].clone())
.map(|bytes| redis_blob_from_bytes(&bytes))
.ok_or_else(|| "Invalid SCAN member payload".to_string())?;
let value = redis_value_to_string(b.clone()).unwrap_or_default();
items.push((member, value));
.ok_or_else(|| "Invalid ZRANGE member payload".to_string())?;
let score = redis_value_to_string(pair[1].clone()).ok_or_else(|| "Invalid ZRANGE score".to_string())?;
items.push(RedisZsetItem { score, member });
}
Ok((cursor, items))
Ok(items)
}
fn parse_scan_hash_entries(raw: RedisRawValue) -> Result<(u64, Vec<RedisHashItem>), String> {
@ -3270,7 +3340,7 @@ mod tests {
redis_value_to_bytes, standalone_connection_infos, RedisAuthCandidate, RedisBlob, RedisBlobEncoding,
RedisClusterSlotRange, RedisCollectionPage, RedisCommandSafety, RedisHashItem, RedisNodeEndpoint,
RedisNodeRoute, RedisRawValue, RedisSetItem, RedisStreamConsumer, RedisStreamEntry, RedisStreamField,
RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisValueData,
RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisValueData, RedisZsetItem,
};
use crate::models::connection::ConnectionConfig;
use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture};
@ -3379,6 +3449,17 @@ mod tests {
RedisRawValue::Array(vec![bulk(cursor), RedisRawValue::Array(entries)])
}
fn zrange_response(pairs: Vec<(&str, &str)>) -> RedisRawValue {
RedisRawValue::Array(pairs.into_iter().flat_map(|(member, score)| [bulk(member), bulk(score)]).collect())
}
fn noperm(command: &str) -> redis::RedisError {
redis::make_extension_error(
"NOPERM".to_string(),
Some(format!("this user has no permissions to run the '{command}' command")),
)
}
fn text_blob(value: &str) -> RedisBlob {
redis_blob_from_bytes(value.as_bytes())
}
@ -4545,7 +4626,8 @@ mod tests {
hscan_response("0", vec![("user:2", "Bob")]),
]);
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 1, Some("user")).await.unwrap();
let result =
super::load_more_collection(&mut con, b"hash-key", "hash", 0, 1, Some("user"), None).await.unwrap();
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
panic!("expected hash collection page");
@ -4561,7 +4643,8 @@ mod tests {
let mut con =
FakeRedisConnection::new(vec![hscan_response("0", vec![("status", "Ada Lovelace"), ("name", "Bob")])]);
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("lovelace")).await.unwrap();
let result =
super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("lovelace"), None).await.unwrap();
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
panic!("expected hash collection page");
@ -4579,7 +4662,8 @@ mod tests {
.collect();
let mut con = FakeRedisConnection::new(responses);
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("missing")).await.unwrap();
let result =
super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("missing"), None).await.unwrap();
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
panic!("expected hash collection page");
@ -4589,6 +4673,127 @@ mod tests {
assert_eq!(con.command_count("HSCAN"), super::HASH_FILTER_SCAN_MAX_ITERATIONS);
}
#[tokio::test]
async fn zset_update_passes_expected_score_to_the_atomic_script() {
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(1)]);
let used_acl_compatibility =
super::zset_update(&mut con, b"scores", "alice", "10", "alice", "20").await.unwrap();
assert!(!used_acl_compatibility);
assert_eq!(con.command_count("EVAL"), 1);
assert_eq!(con.command_count("ZADD"), 0);
assert!(con.commands[0].contains("\r\n$2\r\n10\r\n"));
assert!(con.commands[0].contains("\r\n$2\r\n20\r\n"));
}
#[tokio::test]
async fn zset_update_rejects_a_second_writer_with_a_stale_score() {
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(-2)]);
let error = super::zset_update(&mut con, b"scores", "alice", "10", "alice", "30").await.unwrap_err();
assert!(error.contains("score changed"));
assert_eq!(con.command_count("EVAL"), 1);
assert_eq!(con.commands.len(), 1);
}
#[tokio::test]
async fn zset_update_rejects_a_second_writer_renaming_a_member_with_a_stale_score() {
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(-2)]);
let error = super::zset_update(&mut con, b"scores", "alice", "10", "alicia", "20").await.unwrap_err();
assert!(error.contains("score changed"));
assert_eq!(con.command_count("EVAL"), 1);
assert_eq!(con.command_count("ZADD"), 0);
assert_eq!(con.command_count("ZREM"), 0);
assert_eq!(con.commands.len(), 1);
}
#[tokio::test]
async fn zset_update_rejects_restricted_acl_score_edits_without_writing() {
let mut con = FakeRedisConnection::with_results(vec![Err(noperm("eval"))]);
let error = super::zset_update(&mut con, b"scores", "alice", "10", "alice", "20").await.unwrap_err();
assert!(error.contains("EVAL and ZSCORE permissions"));
assert_eq!(con.command_count("EVAL"), 1);
assert_eq!(con.command_count("ZADD"), 0);
assert_eq!(con.command_count("ZREM"), 0);
assert_eq!(con.commands.len(), 1);
}
#[tokio::test]
async fn zset_update_rejects_restricted_acl_renames_without_writing() {
let mut con = FakeRedisConnection::with_results(vec![Err(noperm("zscore"))]);
let error = super::zset_update(&mut con, b"scores", "alice", "10", "alicia", "20").await.unwrap_err();
assert!(error.contains("EVAL and ZSCORE permissions"));
assert_eq!(con.command_count("EVAL"), 1);
assert_eq!(con.command_count("ZADD"), 0);
assert_eq!(con.command_count("ZREM"), 0);
assert_eq!(con.commands.len(), 1);
}
#[tokio::test]
async fn zset_update_does_not_fallback_for_unrelated_acl_errors() {
let mut con = FakeRedisConnection::with_results(vec![Err(noperm("zrem"))]);
let error = super::zset_update(&mut con, b"scores", "alice", "10", "alicia", "20").await.unwrap_err();
assert!(error.contains("NOPERM"));
assert_eq!(con.command_count("EVAL"), 1);
assert_eq!(con.commands.len(), 1);
}
#[tokio::test]
async fn zset_load_more_uses_ranked_ascending_pages() {
let mut con =
FakeRedisConnection::new(vec![zrange_response(vec![("alice", "1"), ("bob", "2"), ("carol", "3")])]);
let result = super::load_more_collection(&mut con, b"scores", "zset", 0, 2, None, Some("asc")).await.unwrap();
let RedisCollectionPage::Zset { items, scan_cursor } = result else {
panic!("expected zset collection page");
};
assert_eq!(
items,
vec![
RedisZsetItem { score: "1".to_string(), member: text_blob("alice") },
RedisZsetItem { score: "2".to_string(), member: text_blob("bob") },
]
);
assert_eq!(scan_cursor, Some(2));
assert_eq!(con.command_count("ZRANGE"), 1);
assert_eq!(con.command_count("ZREVRANGE"), 0);
assert_eq!(con.command_count("ZSCAN"), 0);
}
#[tokio::test]
async fn zset_load_more_uses_ranked_descending_pages() {
let mut con =
FakeRedisConnection::new(vec![zrange_response(vec![("carol", "3"), ("bob", "2"), ("alice", "1")])]);
let result = super::load_more_collection(&mut con, b"scores", "zset", 0, 2, None, Some("desc")).await.unwrap();
let RedisCollectionPage::Zset { items, scan_cursor } = result else {
panic!("expected zset collection page");
};
assert_eq!(
items,
vec![
RedisZsetItem { score: "3".to_string(), member: text_blob("carol") },
RedisZsetItem { score: "2".to_string(), member: text_blob("bob") },
]
);
assert_eq!(scan_cursor, Some(2));
assert_eq!(con.command_count("ZRANGE"), 0);
assert_eq!(con.command_count("ZREVRANGE"), 1);
assert_eq!(con.command_count("ZSCAN"), 0);
}
#[test]
fn does_not_treat_utf8_with_backslashes_as_binary() {
let raw = RedisRawValue::BulkString(br#"C:\Users\path"#.to_vec());

View File

@ -687,6 +687,38 @@ pub async fn redis_zrem_in_db_core(
}
}
pub async fn redis_zset_update_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
original_member: &str,
expected_score: &str,
member: &str,
score: &str,
) -> Result<bool, String> {
ensure_redis_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(redis) => {
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
match redis {
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut *con, db).await?;
redis_driver::zset_update(&mut *con, &key, original_member, expected_score, member, score).await
}
RedisConnection::Cluster(cluster) => {
redis_driver::ensure_cluster_db(db)?;
let mut con = redis_driver::cluster_key_connection(cluster, &key).await?;
redis_driver::zset_update(&mut con, &key, original_member, expected_score, member, score).await
}
}
}
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_stream_add_in_db_core(
state: &AppState,
connection_id: &str,
@ -941,6 +973,7 @@ pub async fn redis_load_more_in_db_core(
cursor: u64,
count: usize,
filter: Option<&str>,
sort_direction: Option<&str>,
) -> Result<RedisCollectionPage, String> {
ensure_redis_pool(state, connection_id).await?;
let connections = state.connections.read().await;
@ -951,12 +984,14 @@ pub async fn redis_load_more_in_db_core(
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut *con, db).await?;
redis_driver::load_more_collection(&mut *con, &key, key_type, cursor, count, filter).await
redis_driver::load_more_collection(&mut *con, &key, key_type, cursor, count, filter, sort_direction)
.await
}
RedisConnection::Cluster(cluster) => {
redis_driver::ensure_cluster_db(db)?;
let mut con = redis_driver::cluster_key_connection(cluster, &key).await?;
redis_driver::load_more_collection(&mut con, &key, key_type, cursor, count, filter).await
redis_driver::load_more_collection(&mut con, &key, key_type, cursor, count, filter, sort_direction)
.await
}
}
}

View File

@ -512,6 +512,7 @@ async fn main() {
.route("/redis/set-add", post(routes::redis::set_add))
.route("/redis/set-remove", post(routes::redis::set_remove))
.route("/redis/zadd", post(routes::redis::zadd))
.route("/redis/zset-update", post(routes::redis::zset_update))
.route("/redis/stream-add", post(routes::redis::stream_add))
.route("/redis/json-set", post(routes::redis::json_set))
.route("/redis/check-json-module", post(routes::redis::check_json_module))

View File

@ -115,6 +115,7 @@ pub struct RedisLoadMoreRequest {
pub cursor: u64,
pub count: usize,
pub filter: Option<String>,
pub sort_direction: Option<String>,
}
#[derive(Deserialize)]
@ -149,6 +150,18 @@ pub struct RedisZaddRequest {
pub ttl: Option<i64>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisZsetUpdateRequest {
pub connection_id: String,
pub db: u32,
pub key_raw: String,
pub original_member: String,
pub expected_score: String,
pub member: String,
pub score: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisListRequest {
@ -415,6 +428,7 @@ pub async fn load_more(
req.cursor,
req.count,
req.filter.as_deref(),
req.sort_direction.as_deref(),
)
.await
.map_err(AppError::from)?;
@ -570,6 +584,26 @@ pub async fn zadd(State(state): State<Arc<WebState>>, Json(req): Json<RedisZaddR
Ok(Json(()))
}
pub async fn zset_update(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisZsetUpdateRequest>,
) -> Result<Json<bool>, AppError> {
ensure_writable(&state.app, &req.connection_id, "ZADD/ZREM").await?;
let used_acl_compatibility = dbx_core::redis_ops::redis_zset_update_in_db_core(
&state.app,
&req.connection_id,
req.db,
&req.key_raw,
&req.original_member,
&req.expected_score,
&req.member,
&req.score,
)
.await
.map_err(AppError::from)?;
Ok(Json(used_acl_compatibility))
}
pub async fn stream_add(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisStreamAddRequest>,

View File

@ -290,6 +290,31 @@ pub async fn redis_zrem(
dbx_core::redis_ops::redis_zrem_in_db_core(&state, &connection_id, db, &key_raw, &member).await
}
#[tauri::command]
pub async fn redis_zset_update(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
key_raw: String,
original_member: String,
expected_score: String,
member: String,
score: String,
) -> Result<bool, String> {
ensure_connection_writable(&state, &connection_id, "ZADD/ZREM").await?;
dbx_core::redis_ops::redis_zset_update_in_db_core(
&state,
&connection_id,
db,
&key_raw,
&original_member,
&expected_score,
&member,
&score,
)
.await
}
#[tauri::command]
pub async fn redis_stream_add(
state: State<'_, Arc<AppState>>,
@ -407,6 +432,7 @@ pub async fn redis_load_more(
cursor: u64,
count: usize,
filter: Option<String>,
sort_direction: Option<String>,
) -> Result<RedisCollectionPage, String> {
dbx_core::redis_ops::redis_load_more_in_db_core(
&state,
@ -417,6 +443,7 @@ pub async fn redis_load_more(
cursor,
count,
filter.as_deref(),
sort_direction.as_deref(),
)
.await
}

View File

@ -1706,6 +1706,7 @@ pub fn run() {
commands::redis_cmd::redis_set_remove,
commands::redis_cmd::redis_zadd,
commands::redis_cmd::redis_zrem,
commands::redis_cmd::redis_zset_update,
commands::redis_cmd::redis_stream_add,
commands::redis_cmd::redis_json_set,
commands::redis_cmd::redis_check_json_module,