feat(redis): add bulk operations and command runner
This commit is contained in:
parent
f2e8b49df7
commit
368fa5a102
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()))?))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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} 个文档",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue