feat(redis): add bulk operations and command runner

This commit is contained in:
t8y2 2026-05-12 13:38:43 +08:00
parent f2e8b49df7
commit 368fa5a102
17 changed files with 673 additions and 20 deletions

View File

@ -34,6 +34,21 @@ pub struct RedisValue {
pub scan_cursor: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RedisCommandSafety {
Allowed,
Confirm,
Blocked,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisCommandResult {
pub command: String,
pub safety: RedisCommandSafety,
pub value: serde_json::Value,
}
pub async fn connect(url: &str) -> Result<redis::aio::MultiplexedConnection, String> {
let client = redis::Client::open(url).map_err(|e| format!("Redis connection failed: {e}"))?;
let mut con = tokio::time::timeout(super::connection_timeout(), client.get_multiplexed_async_connection())
@ -97,6 +112,153 @@ pub async fn select_db(con: &mut redis::aio::MultiplexedConnection, db: u32) ->
redis::cmd("SELECT").arg(db).query_async(con).await.map_err(|e| e.to_string())
}
pub fn parse_command_argv(command_text: &str) -> Result<Vec<String>, String> {
let mut argv = Vec::new();
let mut current = String::new();
let mut chars = command_text.chars().peekable();
let mut quote: Option<char> = None;
let mut escaping = false;
while let Some(ch) = chars.next() {
if escaping {
current.push(match ch {
'n' => '\n',
'r' => '\r',
't' => '\t',
other => other,
});
escaping = false;
continue;
}
if ch == '\\' {
escaping = true;
continue;
}
if let Some(q) = quote {
if ch == q {
quote = None;
} else {
current.push(ch);
}
continue;
}
if ch == '"' || ch == '\'' {
quote = Some(ch);
continue;
}
if ch.is_whitespace() {
if !current.is_empty() {
argv.push(std::mem::take(&mut current));
}
while matches!(chars.peek(), Some(next) if next.is_whitespace()) {
chars.next();
}
continue;
}
current.push(ch);
}
if escaping {
current.push('\\');
}
if quote.is_some() {
return Err("Redis command has an unterminated quote".to_string());
}
if !current.is_empty() {
argv.push(current);
}
if argv.is_empty() {
return Err("Redis command is empty".to_string());
}
Ok(argv)
}
pub fn classify_command(command: &str) -> RedisCommandSafety {
match command.to_ascii_uppercase().as_str() {
"KEYS" | "FLUSHALL" | "SHUTDOWN" | "CONFIG" | "SAVE" | "BGSAVE" | "SLAVEOF" | "REPLICAOF" | "MIGRATE"
| "MODULE" | "SCRIPT" | "EVAL" | "EVALSHA" => RedisCommandSafety::Blocked,
"DEL" | "UNLINK" | "EXPIRE" | "EXPIREAT" | "PEXPIRE" | "PEXPIREAT" | "PERSIST" | "RENAME" | "RENAMENX"
| "SET" | "SETEX" | "PSETEX" | "SETNX" | "MSET" | "MSETNX" | "HSET" | "HDEL" | "LPUSH" | "RPUSH" | "LPOP"
| "RPOP" | "LSET" | "LREM" | "SADD" | "SREM" | "ZADD" | "ZREM" | "XADD" | "XDEL" | "FLUSHDB" => {
RedisCommandSafety::Confirm
}
_ => RedisCommandSafety::Allowed,
}
}
pub fn redis_command_raw_to_json(value: RedisRawValue) -> serde_json::Value {
match value {
RedisRawValue::Nil => serde_json::Value::Null,
RedisRawValue::Array(values) => {
serde_json::Value::Array(values.into_iter().map(redis_command_raw_to_json).collect())
}
RedisRawValue::Map(values) => serde_json::Value::Array(
values
.into_iter()
.map(|(key, value)| {
serde_json::json!({
"key": redis_command_raw_to_json(key),
"value": redis_command_raw_to_json(value),
})
})
.collect(),
),
RedisRawValue::Set(values) => {
serde_json::Value::Array(values.into_iter().map(redis_command_raw_to_json).collect())
}
RedisRawValue::Attribute { data, attributes } => serde_json::json!({
"data": redis_command_raw_to_json(*data),
"attributes": redis_command_raw_to_json(RedisRawValue::Map(attributes)),
}),
RedisRawValue::Push { kind, data } => serde_json::json!({
"kind": format!("{kind:?}"),
"data": redis_command_raw_to_json(RedisRawValue::Array(data)),
}),
RedisRawValue::BulkString(bytes) => serde_json::Value::String(redis_bytes_to_display(&bytes)),
RedisRawValue::SimpleString(value) => serde_json::Value::String(value),
RedisRawValue::Okay => serde_json::Value::String("OK".to_string()),
RedisRawValue::Int(value) => serde_json::Value::Number(value.into()),
RedisRawValue::Double(value) => {
serde_json::Number::from_f64(value).map_or(serde_json::Value::Null, serde_json::Value::Number)
}
RedisRawValue::Boolean(value) => serde_json::Value::Bool(value),
RedisRawValue::VerbatimString { text, .. } => {
serde_json::Value::String(redis_bytes_to_display(text.as_bytes()))
}
RedisRawValue::BigNumber(value) => serde_json::Value::String(value.to_string()),
RedisRawValue::ServerError(error) => serde_json::Value::String(format!("{error:?}")),
}
}
pub async fn flush_db(con: &mut redis::aio::MultiplexedConnection) -> Result<(), String> {
redis::cmd("FLUSHDB").query_async::<()>(con).await.map_err(|e| e.to_string())
}
pub async fn execute_command(
con: &mut redis::aio::MultiplexedConnection,
command_text: &str,
) -> Result<RedisCommandResult, String> {
let argv = parse_command_argv(command_text)?;
let command = argv[0].to_ascii_uppercase();
let safety = classify_command(&command);
if safety == RedisCommandSafety::Blocked {
return Err(format!("Redis command is blocked for safety: {command}"));
}
let mut cmd = redis::cmd(&argv[0]);
for arg in argv.iter().skip(1) {
cmd.arg(arg);
}
let raw: RedisRawValue = cmd.query_async(con).await.map_err(|e| e.to_string())?;
Ok(RedisCommandResult { command, safety, value: redis_command_raw_to_json(raw) })
}
pub async fn scan_keys_page(
con: &mut redis::aio::MultiplexedConnection,
cursor: u64,
@ -667,8 +829,9 @@ fn parse_scan_members(raw: RedisRawValue) -> Result<(u64, Vec<serde_json::Value>
#[cfg(test)]
mod tests {
use super::{
parse_database_count, parse_scan_keys, parse_stream_entries, redis_key_bytes_to_display,
redis_key_bytes_to_raw, redis_key_raw_to_bytes, redis_raw_to_json, redis_value_contains_binary, RedisRawValue,
classify_command, parse_command_argv, parse_database_count, parse_scan_keys, parse_stream_entries,
redis_command_raw_to_json, redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_raw_to_bytes,
redis_raw_to_json, redis_value_contains_binary, RedisCommandSafety, RedisRawValue,
};
fn bulk(value: &str) -> RedisRawValue {
@ -786,4 +949,37 @@ mod tests {
assert!(!redis_value_contains_binary(&raw));
}
#[test]
fn parses_command_text_with_quotes_and_escapes() {
let argv = parse_command_argv(r#"SET "user:1" "Ada \"Lovelace\"""#).unwrap();
assert_eq!(argv, vec!["SET", "user:1", "Ada \"Lovelace\""]);
}
#[test]
fn rejects_empty_command_text() {
assert_eq!(parse_command_argv(" ").unwrap_err(), "Redis command is empty");
}
#[test]
fn classifies_safe_confirmed_and_blocked_commands() {
assert_eq!(classify_command("GET"), RedisCommandSafety::Allowed);
assert_eq!(classify_command("set"), RedisCommandSafety::Confirm);
assert_eq!(classify_command("flushdb"), RedisCommandSafety::Confirm);
assert_eq!(classify_command("KEYS"), RedisCommandSafety::Blocked);
assert_eq!(classify_command("flushall"), RedisCommandSafety::Blocked);
assert_eq!(classify_command("eval"), RedisCommandSafety::Blocked);
}
#[test]
fn converts_command_results_to_json() {
let raw = RedisRawValue::Array(vec![
RedisRawValue::SimpleString("OK".to_string()),
RedisRawValue::Int(2),
RedisRawValue::Nil,
]);
assert_eq!(redis_command_raw_to_json(raw), serde_json::json!(["OK", 2, null]));
}
}

View File

@ -1,5 +1,5 @@
use crate::connection::{AppState, PoolKind};
use crate::db::redis_driver::{self, RedisScanResult, RedisValue};
use crate::db::redis_driver::{self, RedisCommandResult, RedisScanResult, RedisValue};
pub async fn redis_list_databases_core(state: &AppState, connection_id: &str) -> Result<Vec<u32>, String> {
let connections = state.connections.read().await;
@ -342,6 +342,35 @@ pub async fn redis_delete_keys_in_db_core(
}
}
pub async fn redis_flush_db_core(state: &AppState, connection_id: &str, db: u32) -> Result<(), String> {
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut con, db).await?;
redis_driver::flush_db(&mut con).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_execute_command_core(
state: &AppState,
connection_id: &str,
db: u32,
command: &str,
) -> Result<RedisCommandResult, String> {
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut con, db).await?;
redis_driver::execute_command(&mut con, command).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_load_more_in_db_core(
state: &AppState,
connection_id: &str,

View File

@ -2,7 +2,7 @@ use std::sync::Arc;
use tauri::State;
use crate::commands::connection::AppState;
use dbx_core::db::redis_driver::{RedisScanResult, RedisValue};
use dbx_core::db::redis_driver::{RedisCommandResult, RedisScanResult, RedisValue};
#[tauri::command]
pub async fn redis_list_databases(state: State<'_, Arc<AppState>>, connection_id: String) -> Result<Vec<u32>, String> {
@ -164,6 +164,21 @@ pub async fn redis_delete_keys(
dbx_core::redis_ops::redis_delete_keys_in_db_core(&state, &connection_id, db, &key_raws).await
}
#[tauri::command]
pub async fn redis_flush_db(state: State<'_, Arc<AppState>>, connection_id: String, db: u32) -> Result<(), String> {
dbx_core::redis_ops::redis_flush_db_core(&state, &connection_id, db).await
}
#[tauri::command]
pub async fn redis_execute_command(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
command: String,
) -> Result<RedisCommandResult, String> {
dbx_core::redis_ops::redis_execute_command_core(&state, &connection_id, db, &command).await
}
#[tauri::command]
pub async fn redis_load_more(
state: State<'_, Arc<AppState>>,

View File

@ -120,6 +120,8 @@ pub fn run() {
commands::redis_cmd::redis_zrem,
commands::redis_cmd::redis_set_ttl,
commands::redis_cmd::redis_delete_keys,
commands::redis_cmd::redis_flush_db,
commands::redis_cmd::redis_execute_command,
commands::redis_cmd::redis_load_more,
commands::saved_sql::load_saved_sql_library,
commands::saved_sql::save_saved_sql_folder,

View File

@ -117,6 +117,9 @@ async fn main() {
.route("/redis/list-remove", post(routes::redis::list_remove))
.route("/redis/set-add", post(routes::redis::set_add))
.route("/redis/set-remove", post(routes::redis::set_remove))
.route("/redis/delete-keys", post(routes::redis::delete_keys))
.route("/redis/flush-db", post(routes::redis::flush_db))
.route("/redis/execute-command", post(routes::redis::execute_command))
// MongoDB
.route("/mongo/list-databases", post(routes::mongo::list_databases))
.route("/mongo/list-collections", post(routes::mongo::list_collections))

View File

@ -70,6 +70,29 @@ pub struct RedisSetRequest {
pub member: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisKeysRequest {
pub connection_id: String,
pub db: u32,
pub key_raws: Vec<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisDbRequest {
pub connection_id: String,
pub db: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisCommandRequest {
pub connection_id: String,
pub db: u32,
pub command: String,
}
pub async fn list_databases(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisConnectionRequest>,
@ -202,3 +225,32 @@ pub async fn set_remove(
.map_err(AppError)?;
Ok(Json(()))
}
pub async fn delete_keys(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisKeysRequest>,
) -> Result<Json<u64>, AppError> {
let result =
dbx_core::redis_ops::redis_delete_keys_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raws)
.await
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn flush_db(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisDbRequest>,
) -> Result<Json<()>, AppError> {
dbx_core::redis_ops::redis_flush_db_core(&state.app, &req.connection_id, req.db).await.map_err(AppError)?;
Ok(Json(()))
}
pub async fn execute_command(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisCommandRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = dbx_core::redis_ops::redis_execute_command_core(&state.app, &req.connection_id, req.db, &req.command)
.await
.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}

View File

@ -10,6 +10,9 @@ import {
FolderClosed,
FolderOpen,
Trash2,
DatabaseZap,
Play,
Terminal,
} from "lucide-vue-next";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
@ -25,9 +28,11 @@ import type { RedisKeyInfo } from "@/lib/api";
import {
buildRedisKeyTree,
collectExpandedGroupIds,
collectRedisGroupKeyRaws,
flattenVisibleRedisKeyTree,
type RedisKeyTreeNode,
} from "@/lib/redisKeyTree";
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
const { t } = useI18n();
@ -44,7 +49,17 @@ const selectedKeyRaw = ref<string | null>(null);
const hasMore = ref(false);
const expandedGroupIds = ref<Set<string>>(new Set());
const checkedKeys = ref<Set<string>>(new Set());
const showBatchDeleteConfirm = ref(false);
const pendingDanger = ref<
| { kind: "delete-keys"; title: string; keyRaws: string[] }
| { kind: "flush-db" }
| { kind: "command"; command: string }
| null
>(null);
const showDangerConfirm = ref(false);
const commandText = ref("");
const commandResult = ref<any>(null);
const commandError = ref("");
const commandRunning = ref(false);
const PAGE_SIZE = 200;
const keyGridStyle = {
@ -54,6 +69,22 @@ const keyGridStyle = {
const effectivePattern = computed(() => searchPattern.value.trim() || "*");
const isSearchMode = computed(() => effectivePattern.value !== "*");
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
const dangerDetails = computed(() => {
if (!pendingDanger.value) return "";
if (pendingDanger.value.kind === "delete-keys") {
return t("redis.deleteGroupDetails", {
target: pendingDanger.value.title,
count: pendingDanger.value.keyRaws.length,
});
}
if (pendingDanger.value.kind === "flush-db") return t("redis.flushDbDetails", { db: props.db });
return pendingDanger.value.command;
});
const formattedCommandResult = computed(() => {
if (commandResult.value == null) return "";
if (typeof commandResult.value === "string") return commandResult.value;
return JSON.stringify(commandResult.value, null, 2);
});
const visibleRows = computed(() =>
flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value).map((row) => ({
...row,
@ -139,20 +170,99 @@ function toggleCheck(keyRaw: string, event: Event) {
function requestBatchDelete() {
if (checkedKeys.value.size === 0) return;
showBatchDeleteConfirm.value = true;
pendingDanger.value = { kind: "delete-keys", title: t("redis.selectedKeys"), keyRaws: [...checkedKeys.value] };
showDangerConfirm.value = true;
}
async function applyBatchDelete() {
const keys = [...checkedKeys.value];
function requestGroupDelete(node: RedisKeyTreeNode, event: Event) {
event.stopPropagation();
if (node.kind !== "group") return;
const keyRaws = collectRedisGroupKeyRaws(node);
if (keyRaws.length === 0) return;
pendingDanger.value = { kind: "delete-keys", title: node.pathSegments.join(":"), keyRaws };
showDangerConfirm.value = true;
}
function requestFlushDb() {
pendingDanger.value = { kind: "flush-db" };
showDangerConfirm.value = true;
}
function resetLoadedKeys() {
flatKeys.value = [];
treeKeys.value = [];
selectedKeyRaw.value = null;
checkedKeys.value = new Set();
expandedGroupIds.value = new Set();
hasMore.value = false;
}
async function deleteKeyRaws(keys: string[]) {
await api.redisDeleteKeys(props.connectionId, props.db, keys);
flatKeys.value = flatKeys.value.filter((k) => !checkedKeys.value.has(k.key_raw));
if (selectedKeyRaw.value && checkedKeys.value.has(selectedKeyRaw.value)) {
const deleted = new Set(keys);
flatKeys.value = flatKeys.value.filter((k) => !deleted.has(k.key_raw));
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
checkedKeys.value = new Set();
rebuildTree(false);
}
async function runRedisCommand(command: string) {
commandRunning.value = true;
commandError.value = "";
commandResult.value = null;
try {
const result = await api.redisExecuteCommand(props.connectionId, props.db, command);
commandResult.value = result.value;
if (result.safety === "confirm") {
await loadKeys();
}
} catch (error) {
commandError.value = error instanceof Error ? error.message : String(error);
} finally {
commandRunning.value = false;
}
}
async function executeCommand() {
const command = commandText.value.trim();
if (!command) {
commandError.value = t("redis.commandEmpty");
commandResult.value = null;
return;
}
const safety = classifyRedisCommandSafety(command);
if (safety === "blocked") {
commandError.value = t("redis.commandBlocked");
commandResult.value = null;
return;
}
if (safety === "confirm") {
pendingDanger.value = { kind: "command", command };
showDangerConfirm.value = true;
return;
}
await runRedisCommand(command);
}
async function applyDangerAction() {
const pending = pendingDanger.value;
pendingDanger.value = null;
showDangerConfirm.value = false;
if (!pending) return;
if (pending.kind === "delete-keys") {
await deleteKeyRaws(pending.keyRaws);
} else if (pending.kind === "flush-db") {
await api.redisFlushDb(props.connectionId, props.db);
resetLoadedKeys();
} else {
await runRedisCommand(pending.command);
}
}
function typeColor(type: string): string {
switch (type) {
case "string":
@ -213,6 +323,15 @@ onMounted(loadKeys);
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
<RefreshCw v-else class="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 shrink-0 text-destructive"
:title="t('redis.flushDb')"
@click="requestFlushDb"
>
<DatabaseZap class="h-3 w-3" />
</Button>
<span class="text-xs text-muted-foreground shrink-0 ml-1">{{
loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length })
}}</span>
@ -227,6 +346,35 @@ onMounted(loadKeys);
</Button>
</div>
<div class="min-h-9 flex items-center gap-1 px-2 border-b shrink-0">
<Terminal class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<Input
v-model="commandText"
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0 font-mono"
:placeholder="t('redis.commandPlaceholder')"
@keydown.enter="executeCommand"
/>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 shrink-0"
:title="t('redis.executeCommand')"
:disabled="commandRunning"
@click="executeCommand"
>
<Loader2 v-if="commandRunning" class="h-3 w-3 animate-spin" />
<Play v-else class="h-3 w-3" />
</Button>
</div>
<div v-if="commandError || formattedCommandResult" class="border-b px-2 py-1 shrink-0 text-xs">
<pre
class="max-h-24 overflow-auto whitespace-pre-wrap break-words font-mono"
:class="commandError ? 'text-destructive' : 'text-muted-foreground'"
>{{ commandError || formattedCommandResult }}</pre
>
</div>
<!-- Table header -->
<div class="grid border-b bg-muted/50 shrink-0 text-xs font-medium text-muted-foreground" :style="keyGridStyle">
<div class="px-3 py-1 border-r">{{ t("redis.columnKey") }}</div>
@ -274,6 +422,15 @@ onMounted(loadKeys);
/>
<span class="truncate font-mono">{{ row.node.label }}</span>
<span class="text-muted-foreground ml-1">({{ countLeaves(row.node) }})</span>
<Button
variant="ghost"
size="icon"
class="ml-auto h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100"
:title="t('redis.deleteGroup')"
@click="requestGroupDelete(row.node, $event)"
>
<Trash2 class="h-3 w-3" />
</Button>
</template>
<template v-else>
<input
@ -336,10 +493,10 @@ onMounted(loadKeys);
</Splitpanes>
<DangerConfirmDialog
v-model:open="showBatchDeleteConfirm"
v-model:open="showDangerConfirm"
:message="t('dangerDialog.deleteMessage')"
:details="`${checkedKeys.size} keys`"
:confirm-label="t('dangerDialog.deleteConfirm')"
@confirm="applyBatchDelete"
:details="dangerDetails"
:confirm-label="pendingDanger?.kind === 'command' ? t('dangerDialog.confirm') : t('dangerDialog.deleteConfirm')"
@confirm="applyDangerAction"
/>
</template>

View File

@ -684,6 +684,15 @@ export default {
columnTTL: "TTL",
binaryStringReadonlyHint:
"Binary string values are shown as escaped text in read-only mode; editing raw bytes is not supported.",
selectedKeys: "Selected keys",
deleteGroup: "Delete group",
deleteGroupDetails: "{target}\n{count} keys",
flushDb: "Clear current DB",
flushDbDetails: "Redis db{db}",
commandPlaceholder: "Redis command, e.g. GET user:1",
executeCommand: "Execute command",
commandEmpty: "Enter a Redis command",
commandBlocked: "This Redis command is blocked for safety",
},
mongo: {
documents: "{count} documents",

View File

@ -678,8 +678,22 @@ export default {
members: "{count} miembros",
entries: "{count} entradas",
noExpiry: "sin expiración",
columnType: "Tipo",
columnKey: "Clave",
columnValue: "Valor",
columnSize: "Tamaño",
columnTTL: "TTL",
binaryStringReadonlyHint:
"Los valores de cadena binaria se muestran como texto escapado en modo de solo lectura; la edición de bytes sin procesar no está disponible.",
selectedKeys: "Claves seleccionadas",
deleteGroup: "Eliminar grupo",
deleteGroupDetails: "{target}\n{count} claves",
flushDb: "Limpiar DB actual",
flushDbDetails: "Redis db{db}",
commandPlaceholder: "Comando Redis, p. ej. GET user:1",
executeCommand: "Ejecutar comando",
commandEmpty: "Ingresa un comando Redis",
commandBlocked: "Este comando Redis está bloqueado por seguridad",
},
mongo: {
documents: "{count} documentos",

View File

@ -670,6 +670,15 @@ export default {
columnSize: "大小",
columnTTL: "TTL",
binaryStringReadonlyHint: "二进制字符串按转义文本只读展示;当前不支持直接编辑原始字节值。",
selectedKeys: "已选择的 key",
deleteGroup: "删除分组",
deleteGroupDetails: "{target}\n{count} 个 key",
flushDb: "清空当前 DB",
flushDbDetails: "Redis db{db}",
commandPlaceholder: "Redis 命令,如 GET user:1",
executeCommand: "执行命令",
commandEmpty: "请输入 Redis 命令",
commandBlocked: "出于安全考虑,此 Redis 命令已被阻止",
},
mongo: {
documents: "{count} 个文档",

View File

@ -114,6 +114,8 @@ export const redisZadd = forward("redisZadd");
export const redisZrem = forward("redisZrem");
export const redisSetTtl = forward("redisSetTtl");
export const redisDeleteKeys = forward("redisDeleteKeys");
export const redisFlushDb = forward("redisFlushDb");
export const redisExecuteCommand = forward("redisExecuteCommand");
export const redisLoadMore = forward("redisLoadMore");
// MongoDB
@ -152,6 +154,8 @@ export type {
RedisKeyInfo,
RedisValue,
RedisScanResult,
RedisCommandSafety,
RedisCommandResult,
MongoDocumentResult,
HistoryEntry,
SqlFileStatus,

View File

@ -26,6 +26,7 @@ import type {
UpdateInfo,
RedisValue,
RedisScanResult,
RedisCommandResult,
MongoDocumentResult,
HistoryEntry,
SqlFileRequest,
@ -584,6 +585,18 @@ export async function redisDeleteKeys(connectionId: string, db: number, keyRaws:
return post("/api/redis/delete-keys", { connectionId, db, keyRaws });
}
export async function redisFlushDb(connectionId: string, db: number): Promise<void> {
return post("/api/redis/flush-db", { connectionId, db });
}
export async function redisExecuteCommand(
connectionId: string,
db: number,
command: string,
): Promise<RedisCommandResult> {
return post("/api/redis/execute-command", { connectionId, db, command });
}
export async function redisLoadMore(
connectionId: string,
db: number,

View File

@ -0,0 +1,82 @@
export type RedisCommandSafety = "allowed" | "confirm" | "blocked";
const BLOCKED_COMMANDS = new Set([
"KEYS",
"FLUSHALL",
"SHUTDOWN",
"CONFIG",
"SAVE",
"BGSAVE",
"SLAVEOF",
"REPLICAOF",
"MIGRATE",
"MODULE",
"SCRIPT",
"EVAL",
"EVALSHA",
]);
const CONFIRM_COMMANDS = new Set([
"DEL",
"UNLINK",
"EXPIRE",
"EXPIREAT",
"PEXPIRE",
"PEXPIREAT",
"PERSIST",
"RENAME",
"RENAMENX",
"SET",
"SETEX",
"PSETEX",
"SETNX",
"MSET",
"MSETNX",
"HSET",
"HDEL",
"LPUSH",
"RPUSH",
"LPOP",
"RPOP",
"LSET",
"LREM",
"SADD",
"SREM",
"ZADD",
"ZREM",
"XADD",
"XDEL",
"FLUSHDB",
]);
export function firstRedisCommandToken(command: string): string {
const trimmed = command.trimStart();
if (!trimmed) return "";
const quote = trimmed[0] === '"' || trimmed[0] === "'" ? trimmed[0] : "";
let token = "";
let escaping = false;
for (let i = quote ? 1 : 0; i < trimmed.length; i++) {
const ch = trimmed[i];
if (escaping) {
token += ch;
escaping = false;
continue;
}
if (ch === "\\") {
escaping = true;
continue;
}
if (quote && ch === quote) break;
if (!quote && /\s/.test(ch)) break;
token += ch;
}
return token.toUpperCase();
}
export function classifyRedisCommandSafety(command: string): RedisCommandSafety {
const token = firstRedisCommandToken(command);
if (BLOCKED_COMMANDS.has(token)) return "blocked";
if (CONFIRM_COMMANDS.has(token)) return "confirm";
return "allowed";
}

View File

@ -129,6 +129,23 @@ export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set<string>
return ids;
}
export function collectRedisGroupKeyRaws(group: RedisKeyTreeGroupNode): string[] {
const keyRaws: string[] = [];
const visit = (nodes: RedisKeyTreeNode[]) => {
for (const node of nodes) {
if (node.kind === "leaf") {
keyRaws.push(node.keyRaw);
} else {
visit(node.children);
}
}
};
visit(group.children);
return keyRaws;
}
export function flattenVisibleRedisKeyTree(
nodes: RedisKeyTreeNode[],
expandedGroupIds: ReadonlySet<string>,

View File

@ -365,6 +365,14 @@ export interface RedisScanResult {
keys: RedisKeyInfo[];
}
export type RedisCommandSafety = "allowed" | "confirm" | "blocked";
export interface RedisCommandResult {
command: string;
safety: RedisCommandSafety;
value: any;
}
export async function redisListDatabases(connectionId: string): Promise<number[]> {
return invoke("redis_list_databases", { connectionId });
}
@ -449,6 +457,18 @@ export async function redisDeleteKeys(connectionId: string, db: number, keyRaws:
return invoke("redis_delete_keys", { connectionId, db, keyRaws });
}
export async function redisFlushDb(connectionId: string, db: number): Promise<void> {
return invoke("redis_flush_db", { connectionId, db });
}
export async function redisExecuteCommand(
connectionId: string,
db: number,
command: string,
): Promise<RedisCommandResult> {
return invoke("redis_execute_command", { connectionId, db, command });
}
export async function redisLoadMore(
connectionId: string,
db: number,

View File

@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { classifyRedisCommandSafety, firstRedisCommandToken } from "../src/lib/redisCommandSafety.ts";
test("firstRedisCommandToken reads the first command token case-insensitively", () => {
assert.equal(firstRedisCommandToken(" get user:1"), "GET");
assert.equal(firstRedisCommandToken('"set" user:1 Ada'), "SET");
});
test("classifyRedisCommandSafety separates allowed confirmed and blocked commands", () => {
assert.equal(classifyRedisCommandSafety("GET user:1"), "allowed");
assert.equal(classifyRedisCommandSafety("set user:1 Ada"), "confirm");
assert.equal(classifyRedisCommandSafety("FLUSHDB"), "confirm");
assert.equal(classifyRedisCommandSafety("KEYS *"), "blocked");
assert.equal(classifyRedisCommandSafety("flushall"), "blocked");
assert.equal(classifyRedisCommandSafety("eval return 1 0"), "blocked");
});

View File

@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
buildRedisKeyTree,
collectRedisGroupKeyRaws,
collectExpandedGroupIds,
flattenVisibleRedisKeyTree,
type RedisKeyTreeNode,
@ -72,11 +73,24 @@ test("collectExpandedGroupIds and flattenVisibleRedisKeyTree expand all search p
assert.deepEqual(
rows.map(({ node, depth }) => `${depth}:${node.kind}:${node.label}`),
[
"0:group:user",
"1:group:profile",
"2:leaf:name",
"1:leaf:settings",
],
["0:group:user", "1:group:profile", "2:leaf:name", "1:leaf:settings"],
);
});
test("collectRedisGroupKeyRaws returns every leaf key under a group", () => {
const tree = buildRedisKeyTree(
[
makeKey("user:profile:name", "k1"),
makeKey("user:profile:email", "k2"),
makeKey("user:settings", "k3"),
makeKey("session:1", "k4"),
],
0,
);
const userGroup = tree.find((node) => node.kind === "group" && node.label === "user");
assert.ok(userGroup);
if (!userGroup || userGroup.kind !== "group") return;
assert.deepEqual(collectRedisGroupKeyRaws(userGroup), ["k2", "k1", "k3"]);
});