feat(redis): support renaming keys

This commit is contained in:
二丫讲梵
2026-08-18 15:23:08 +08:00
committed by GitHub
parent 61103523de
commit 168f2a472b
21 changed files with 297 additions and 2 deletions
@@ -875,6 +875,38 @@ function onKeyDeleted(keyRaw: string) {
removeKnownKey(keyRaw);
}
function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: string) {
connectionStore.invalidateCompletionCache(props.connectionId, String(props.db));
if (isSearchMode.value) {
void loadKeys();
return;
}
const previous = flatKeys.value.find((key) => key.key_raw === oldKeyRaw);
if (!previous) {
void loadKeys();
return;
}
loadedKeyRaws.delete(oldKeyRaw);
loadedKeyRaws.add(newKeyRaw);
flatKeys.value = flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key));
if (selectedKeyRaw.value === oldKeyRaw) selectedKeyRaw.value = newKeyRaw;
if (checkedKeys.value.has(oldKeyRaw)) {
const nextCheckedKeys = new Set(checkedKeys.value);
nextCheckedKeys.delete(oldKeyRaw);
nextCheckedKeys.add(newKeyRaw);
checkedKeys.value = nextCheckedKeys;
}
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
refreshSelectedGroupLeafCounts();
} else {
rebuildTree(false);
}
}
function redisValueToKeyInfo(value: RedisValue): RedisKeyInfo {
return {
key_display: value.key_display,
@@ -2093,7 +2125,19 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</div>
<TabsContent value="detail" class="m-0 min-h-0 flex-1 flex flex-col">
<RedisValueViewer v-if="selectedKey" ref="valueViewerRef" :key="selectedKey.key_raw" :connection-id="connectionId" :db="db" :key-display="selectedKey.key_display" :key-raw="selectedKey.key_raw" :metadata="selectedKey" @deleted="onKeyDeleted" @loaded="onKeyLoaded" />
<RedisValueViewer
v-if="selectedKey"
ref="valueViewerRef"
:key="selectedKey.key_raw"
:connection-id="connectionId"
:db="db"
:key-display="selectedKey.key_display"
:key-raw="selectedKey.key_raw"
:metadata="selectedKey"
@deleted="onKeyDeleted"
@renamed="onKeyRenamed"
@loaded="onKeyLoaded"
/>
<div v-else class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
{{ t("redis.selectKeyForDetail") }}
</div>
@@ -55,6 +55,7 @@ import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
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 { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
const { t, locale } = useI18n();
const { toast } = useToast();
@@ -74,7 +75,7 @@ const redisExpiryTransport = {
setExpireAt: api.redisSetExpireAt,
};
const emit = defineEmits<{ deleted: [keyRaw: string]; loaded: [value: RedisValue] }>();
const emit = defineEmits<{ deleted: [keyRaw: string]; loaded: [value: RedisValue]; renamed: [oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: string] }>();
const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap";
const REDIS_JSON_UNICODE_MODE_STORAGE_KEY = "dbx-redis-json-unicode-mode";
@@ -90,6 +91,12 @@ const REDIS_STREAM_MIN_ROW_HEIGHT = 96;
const data = ref<RedisValue | null>(null);
const loading = ref(false);
const loadingMore = ref(false);
const showRenameKeyDialog = ref(false);
const renamingKey = ref(false);
const renameKeyName = ref("");
const renameKeyError = ref("");
const renameKeyInitialName = ref("");
const renameKeyInitialRaw = ref("");
let loadRequestId = 0;
const streamTab = ref<"entries" | "groups">("entries");
const streamEntries = ref<RedisStreamEntry[]>([]);
@@ -1474,6 +1481,50 @@ function requestDeleteKey() {
showDeleteConfirm.value = true;
}
function openRenameKeyDialog() {
if (!data.value || renamingKey.value) return;
// Display text escapes backslashes and binary bytes. Prefer the original
// UTF-8 text when available so confirming an unchanged name is a true no-op.
renameKeyName.value = redisKeyRawToText(props.keyRaw) ?? data.value.key_display;
renameKeyInitialName.value = renameKeyName.value;
renameKeyInitialRaw.value = props.keyRaw;
renameKeyError.value = "";
showRenameKeyDialog.value = true;
}
function handleRenameKeyDialogOpenChange(open: boolean) {
if (renamingKey.value) return;
showRenameKeyDialog.value = open;
if (!open) renameKeyError.value = "";
}
async function renameKey() {
if (renamingKey.value) return;
if (!renameKeyName.value) {
renameKeyError.value = t("redis.renameKeyNameRequired");
return;
}
const newKeyRaw = renameKeyName.value === renameKeyInitialName.value ? renameKeyInitialRaw.value : redisKeyTextToRaw(renameKeyName.value);
if (newKeyRaw === props.keyRaw) {
showRenameKeyDialog.value = false;
return;
}
renamingKey.value = true;
renameKeyError.value = "";
try {
await api.redisRenameKey(props.connectionId, props.db, props.keyRaw, newKeyRaw);
toast(t("redis.renameKeySuccess", { oldName: props.keyDisplay, newName: renameKeyName.value }), 2000);
showRenameKeyDialog.value = false;
emit("renamed", props.keyRaw, newKeyRaw, redisKeyTextToDisplay(renameKeyName.value));
} catch (error) {
renameKeyError.value = error instanceof Error ? error.message : String(error);
} finally {
renamingKey.value = false;
}
}
async function copyValue() {
if (!data.value) return;
// Copy the decompressed text while the Decompressed view is showing it.
@@ -2497,6 +2548,7 @@ defineExpose({ focusSearch });
</div>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyValue')" :aria-label="t('grid.copyValue')" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.copyInsertStatement')" :aria-label="t('redis.copyInsertStatement')" @click="copyInsertStatement"><ClipboardCopy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.renameKey')" :aria-label="t('redis.renameKey')" @click="openRenameKeyDialog"><Pencil class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
@@ -3184,6 +3236,27 @@ defineExpose({ focusSearch });
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
<Dialog :open="showRenameKeyDialog" @update:open="handleRenameKeyDialogOpenChange">
<DialogContent class="sm:max-w-md" :show-close-button="!renamingKey" :style="editorFontFamilyStyle">
<DialogHeader>
<DialogTitle>{{ t("redis.renameKey") }}</DialogTitle>
</DialogHeader>
<label class="grid gap-1.5 text-xs font-medium">
<span>{{ t("redis.renameKeyName") }}</span>
<Input v-model="renameKeyName" class="dbx-editor-font-family" :disabled="renamingKey" autofocus spellcheck="false" @keydown.enter.prevent="renameKey" />
</label>
<p v-if="renameKeyError" class="text-xs text-destructive">{{ renameKeyError }}</p>
<DialogFooter>
<Button variant="ghost" :disabled="renamingKey" @click="showRenameKeyDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="renamingKey || !renameKeyName" @click="renameKey">
<Loader2 v-if="renamingKey" class="mr-1 h-3.5 w-3.5 animate-spin" />
<Pencil v-else class="mr-1 h-3.5 w-3.5" />
{{ t("redis.renameKey") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :open="showHashFieldTtlDialog" @update:open="handleHashFieldTtlOpenChange">
<DialogContent class="w-[calc(100vw-2rem)] sm:max-w-[460px]">
<DialogHeader>
+4
View File
@@ -4409,6 +4409,10 @@ export default {
createValuePlaceholder: "value",
createKeySubmit: "Create",
createKeyNameRequired: "Enter a key name",
renameKey: "Rename Key",
renameKeyName: "Key name",
renameKeyNameRequired: "Enter a key name",
renameKeySuccess: 'Renamed "{oldName}" to "{newName}"',
createFieldRequired: "Enter a hash field",
createScoreInvalid: "Enter a valid score",
jsonModuleNotAvailable: "ReJSON module is not loaded on this Redis server",
+4
View File
@@ -4247,6 +4247,10 @@ export default withEnglishFallback({
jsonModuleNotAvailable: "El módulo ReJSON no está cargado en este servidor Redis",
createKeySubmit: "Crear",
createKeyNameRequired: "Ingresa una clave",
renameKey: "Renombrar clave",
renameKeyName: "Nombre de la clave",
renameKeyNameRequired: "Ingresa un nombre de clave",
renameKeySuccess: 'Clave "{oldName}" renombrada a "{newName}"',
createFieldRequired: "Ingresa un campo hash",
createScoreInvalid: "Ingresa una puntuación válida",
fieldRequired: "Ingresa un nombre de campo",
+4
View File
@@ -4244,6 +4244,10 @@ export default withEnglishFallback({
createValuePlaceholder: "valore",
createKeySubmit: "Crea",
createKeyNameRequired: "Inserisci un nome chiave",
renameKey: "Rinomina chiave",
renameKeyName: "Nome chiave",
renameKeyNameRequired: "Inserisci un nome chiave",
renameKeySuccess: 'Chiave "{oldName}" rinominata in "{newName}"',
createFieldRequired: "Inserisci un campo hash",
createScoreInvalid: "Inserisci un punteggio valido",
jsonModuleNotAvailable: "Il modulo ReJSON non è caricato su questo server Redis",
+4
View File
@@ -4274,6 +4274,10 @@ export default withEnglishFallback({
createValuePlaceholder: "value",
createKeySubmit: "作成",
createKeyNameRequired: "キー名を入力してください",
renameKey: "キー名を変更",
renameKeyName: "キー名",
renameKeyNameRequired: "キー名を入力してください",
renameKeySuccess: "「{oldName}」を「{newName}」に変更しました",
createFieldRequired: "ハッシュフィールドを入力してください",
createScoreInvalid: "有効なスコアを入力してください",
jsonModuleNotAvailable: "このRedisサーバーにReJSONモジュールがロードされていません",
+4
View File
@@ -3893,6 +3893,10 @@ export default withEnglishFallback({
createValuePlaceholder: "값",
createKeySubmit: "만들기",
createKeyNameRequired: "키 이름을 입력하세요",
renameKey: "키 이름 변경",
renameKeyName: "키 이름",
renameKeyNameRequired: "키 이름을 입력하세요",
renameKeySuccess: '"{oldName}"을(를) "{newName}"(으)로 변경했습니다',
createFieldRequired: "해시 필드를 입력하세요",
createScoreInvalid: "올바른 점수를 입력하세요",
jsonModuleNotAvailable: "이 Redis 서버에 ReJSON 모듈이 로드되지 않았습니다",
+4
View File
@@ -4247,6 +4247,10 @@ export default withEnglishFallback({
jsonModuleNotAvailable: "Módulo ReJSON não está carregado neste servidor Redis",
createKeySubmit: "Criar",
createKeyNameRequired: "Insira um nome de chave",
renameKey: "Renomear chave",
renameKeyName: "Nome da chave",
renameKeyNameRequired: "Insira um nome de chave",
renameKeySuccess: 'Chave "{oldName}" renomeada para "{newName}"',
createFieldRequired: "Insira um campo de hash",
createScoreInvalid: "Insira uma pontuação válida",
fieldRequired: "Insira um nome de campo",
+4
View File
@@ -4396,6 +4396,10 @@ export default withEnglishFallback({
createValuePlaceholder: "value",
createKeySubmit: "新增",
createKeyNameRequired: "请输入 key 名称",
renameKey: "重命名 Key",
renameKeyName: "Key 名称",
renameKeyNameRequired: "请输入 Key 名称",
renameKeySuccess: "已将「{oldName}」重命名为「{newName}」",
createFieldRequired: "请输入 hash 字段",
createScoreInvalid: "请输入有效分数",
jsonModuleNotAvailable: "该 Redis 服务器未加载 ReJSON 模块",
+4
View File
@@ -3573,6 +3573,10 @@ export default withEnglishFallback({
createValuePlaceholder: "value",
createKeySubmit: "建立",
createKeyNameRequired: "請輸入 key 名稱",
renameKey: "重新命名 Key",
renameKeyName: "Key 名稱",
renameKeyNameRequired: "請輸入 Key 名稱",
renameKeySuccess: "已將「{oldName}」重新命名為「{newName}」",
createFieldRequired: "請輸入 hash 欄位",
createScoreInvalid: "請輸入有效分數",
jsonModuleNotAvailable: "該 Redis 伺服器未載入 ReJSON 模組",
+1
View File
@@ -443,6 +443,7 @@ export const redisGetStreamConsumers = forward("redisGetStreamConsumers");
export const redisGetStreamPending = forward("redisGetStreamPending");
export const redisSetString = forward("redisSetString");
export const redisDeleteKey = forward("redisDeleteKey");
export const redisRenameKey = forward("redisRenameKey");
export const redisHashSet = forward("redisHashSet");
export const redisHashDel = forward("redisHashDel");
export const redisHashFieldSetTtl = forward("redisHashFieldSetTtl");
+4
View File
@@ -2535,6 +2535,10 @@ export async function redisDeleteKey(connectionId: string, db: number, keyRaw: s
return post("/api/redis/delete-key", { connectionId, db, keyRaw });
}
export async function redisRenameKey(connectionId: string, db: number, keyRaw: string, newKeyRaw: string): Promise<void> {
return post("/api/redis/rename-key", { connectionId, db, keyRaw, newKeyRaw });
}
export async function redisHashSet(connectionId: string, db: number, keyRaw: string, field: string, value: string, ttl?: number): Promise<void> {
return post("/api/redis/hash-set", {
connectionId,
+4
View File
@@ -2342,6 +2342,10 @@ export async function redisDeleteKey(connectionId: string, db: number, keyRaw: s
return invoke("redis_delete_key", { connectionId, db, keyRaw });
}
export async function redisRenameKey(connectionId: string, db: number, keyRaw: string, newKeyRaw: string): Promise<void> {
return invoke("redis_rename_key", { connectionId, db, keyRaw, newKeyRaw });
}
export async function redisHashSet(connectionId: string, db: number, keyRaw: string, field: string, value: string, ttl?: number): Promise<void> {
return invoke("redis_hash_set", {
connectionId,
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "./redisCommandSession";
describe("Redis key text encoding", () => {
it("round-trips UTF-8 keys without treating backslashes as display escapes", () => {
const key = "cache\\session";
const raw = redisKeyTextToRaw(key);
expect(redisKeyRawToText(raw)).toBe(key);
expect(redisKeyTextToDisplay(key)).toBe("cache\\\\session");
});
it("does not coerce binary key bytes into a text key name", () => {
expect(redisKeyRawToText("/w==")).toBeNull();
});
});
@@ -20,3 +20,17 @@ export function redisKeyTextToRaw(key: string): string {
}
return btoa(binary);
}
/** Returns null when a Redis key contains bytes that cannot be represented in a text input. */
export function redisKeyRawToText(keyRaw: string): string | null {
try {
const bytes = Uint8Array.from(atob(keyRaw), (character) => character.charCodeAt(0));
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
}
export function redisKeyTextToDisplay(key: string): string {
return key.replaceAll("\\", "\\\\");
}
+35
View File
@@ -2862,6 +2862,20 @@ where
redis::cmd("DEL").arg(key).query_async::<()>(con).await.map_err(|e| e.to_string())
}
/// Rename a key without overwriting an existing destination.
pub async fn rename_key<C>(con: &mut C, key: &[u8], new_key: &[u8]) -> Result<(), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let renamed =
redis::cmd("RENAMENX").arg(key).arg(new_key).query_async::<bool>(con).await.map_err(|e| e.to_string())?;
if renamed {
Ok(())
} else {
Err("Target key already exists".to_string())
}
}
async fn apply_expire_if_needed<C>(con: &mut C, key: &[u8], ttl: Option<i64>) -> Result<(), String>
where
C: ConnectionLike + Send + Sync + Unpin,
@@ -4177,6 +4191,27 @@ mod tests {
assert!(!con.commands[0].contains("\r\nEXPIRE\r\n"));
}
#[tokio::test]
async fn rename_key_uses_renamenx_without_overwriting_the_destination() {
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(1)]);
super::rename_key(&mut con, b"session:old", b"session:new").await.unwrap();
assert_eq!(con.commands.len(), 1);
assert!(con.commands[0].contains("\r\nRENAMENX\r\n"));
assert!(con.commands[0].contains("\r\nsession:old\r\n"));
assert!(con.commands[0].contains("\r\nsession:new\r\n"));
}
#[tokio::test]
async fn rename_key_reports_a_destination_conflict() {
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(0)]);
let error = super::rename_key(&mut con, b"session:old", b"session:new").await.unwrap_err();
assert_eq!(error, "Target key already exists");
}
#[tokio::test]
async fn set_expire_at_uses_expireat_with_the_unix_timestamp() {
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(1)]);
+32
View File
@@ -366,6 +366,38 @@ pub async fn redis_delete_key_in_db_core(
}
}
pub async fn redis_rename_key_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
new_key_raw: &str,
) -> Result<(), String> {
ensure_redis_pool(state, connection_id).await?;
let connections = state.connections.read().await;
let pool = connections.get(connection_id).ok_or("Connection not found")?;
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
let new_key = redis_driver::redis_key_raw_to_bytes(new_key_raw)?;
if key == new_key {
return Ok(());
}
match pool {
PoolKind::Redis(redis) => match redis {
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut *con, db).await?;
redis_driver::rename_key(&mut *con, &key, &new_key).await
}
RedisConnection::Cluster(cluster) => {
redis_driver::ensure_cluster_db(db)?;
let mut con = redis_driver::cluster_key_connection(cluster, &key).await?;
redis_driver::rename_key(&mut con, &key, &new_key).await
}
},
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_hash_set_core(
state: &AppState,
connection_id: &str,
+1
View File
@@ -564,6 +564,7 @@ async fn main() {
.route("/redis/load-more", post(routes::redis::load_more))
.route("/redis/set-string", post(routes::redis::set_string))
.route("/redis/delete-key", post(routes::redis::delete_key))
.route("/redis/rename-key", post(routes::redis::rename_key))
.route("/redis/hash-set", post(routes::redis::hash_set))
.route("/redis/hash-del", post(routes::redis::hash_del))
.route("/redis/hash-field-set-ttl", post(routes::redis::hash_field_set_ttl))
+26
View File
@@ -76,6 +76,15 @@ pub struct RedisKeyRequest {
pub key_raw: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisRenameKeyRequest {
pub connection_id: String,
pub db: u32,
pub key_raw: String,
pub new_key_raw: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisStreamEntriesRequest {
@@ -484,6 +493,23 @@ pub async fn delete_key(
Ok(Json(()))
}
pub async fn rename_key(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisRenameKeyRequest>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "RENAMENX").await?;
dbx_core::redis_ops::redis_rename_key_in_db_core(
&state.app,
&req.connection_id,
req.db,
&req.key_raw,
&req.new_key_raw,
)
.await
.map_err(AppError::from)?;
Ok(Json(()))
}
pub async fn hash_set(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisHashRequest>,
+12
View File
@@ -175,6 +175,18 @@ pub async fn redis_delete_key(
dbx_core::redis_ops::redis_delete_key_in_db_core(&state, &connection_id, db, &key_raw).await
}
#[tauri::command]
pub async fn redis_rename_key(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
key_raw: String,
new_key_raw: String,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "RENAMENX").await?;
dbx_core::redis_ops::redis_rename_key_in_db_core(&state, &connection_id, db, &key_raw, &new_key_raw).await
}
#[tauri::command]
pub async fn redis_hash_set(
state: State<'_, Arc<AppState>>,
+1
View File
@@ -1782,6 +1782,7 @@ pub fn run() {
commands::redis_cmd::redis_get_stream_pending,
commands::redis_cmd::redis_set_string,
commands::redis_cmd::redis_delete_key,
commands::redis_cmd::redis_rename_key,
commands::redis_cmd::redis_hash_set,
commands::redis_cmd::redis_hash_del,
commands::redis_cmd::redis_hash_field_set_ttl,