feat(redis): 修复全库键浏览与二进制展示

This commit is contained in:
Bacon2994 2026-05-08 10:06:16 +08:00
parent 68c0639e3a
commit 0fb6aebd59
13 changed files with 879 additions and 174 deletions

1
Cargo.lock generated
View File

@ -1662,6 +1662,7 @@ name = "dbx-core"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"calamine",
"chrono",
"csv",

View File

@ -29,3 +29,4 @@ csv = "1"
calamine = "0.30.1"
odbc-api = "25"
rust-gaussdb = { git = "https://github.com/t8y2/rust-gaussdb", branch = "main" }
base64 = "0.22"

View File

@ -287,6 +287,7 @@ mod tests {
ssh_expose_lan: false,
ssl: false,
connection_string: None,
sysdba: false,
}
}

View File

@ -1,4 +1,5 @@
use redis::{AsyncCommands, FromRedisValue, Value as RedisRawValue};
use base64::Engine;
use redis::{FromRedisValue, Value as RedisRawValue};
use serde::{Deserialize, Serialize};
const STREAM_ENTRY_LIMIT: usize = 100;
@ -6,7 +7,8 @@ const DEFAULT_REDIS_DATABASES: u32 = 16;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisKeyInfo {
pub key: String,
pub key_display: String,
pub key_raw: String,
pub key_type: String,
pub ttl: i64,
}
@ -19,9 +21,11 @@ pub struct RedisScanResult {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisValue {
pub key: String,
pub key_display: String,
pub key_raw: String,
pub key_type: String,
pub ttl: i64,
pub value_is_binary: bool,
pub value: serde_json::Value,
}
@ -94,7 +98,7 @@ pub async fn scan_keys_page(
pattern: &str,
count: usize,
) -> Result<RedisScanResult, String> {
let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
let raw: RedisRawValue = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
@ -104,56 +108,76 @@ pub async fn scan_keys_page(
.await
.map_err(|e| e.to_string())?;
let (next_cursor, keys) = parse_scan_keys(raw)?;
let mut result = Vec::new();
for key in &keys {
let key_type: String =
redis::cmd("TYPE").arg(key.as_str()).query_async(con).await.unwrap_or_else(|_| "unknown".to_string());
redis::cmd("TYPE").arg(key).query_async(con).await.unwrap_or_else(|_| "unknown".to_string());
let ttl: i64 = con.ttl(key.as_str()).await.unwrap_or(-1);
let ttl: i64 = redis::cmd("TTL").arg(key).query_async(con).await.unwrap_or(-1);
result.push(RedisKeyInfo { key: key.clone(), key_type, ttl });
result.push(RedisKeyInfo {
key_display: redis_key_bytes_to_display(key),
key_raw: redis_key_bytes_to_raw(key),
key_type,
ttl,
});
}
Ok(RedisScanResult { cursor: next_cursor, keys: result })
}
pub async fn get_value(con: &mut redis::aio::MultiplexedConnection, key: &str) -> Result<RedisValue, String> {
pub async fn get_value(con: &mut redis::aio::MultiplexedConnection, key: &[u8]) -> Result<RedisValue, String> {
let key_type: String = redis::cmd("TYPE").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
let ttl: i64 = con.ttl(key).await.unwrap_or(-1);
let ttl: i64 = redis::cmd("TTL").arg(key).query_async(con).await.unwrap_or(-1);
let value = match key_type.as_str() {
let (value, value_is_binary) = match key_type.as_str() {
"string" => {
let v: String = con.get(key).await.map_err(|e| e.to_string())?;
serde_json::Value::String(v)
let v: RedisRawValue = redis::cmd("GET").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
let value_is_binary = redis_value_contains_binary(&v);
(redis_raw_to_json(v), value_is_binary)
}
"list" => {
let v: Vec<String> = con.lrange(key, 0, -1).await.map_err(|e| e.to_string())?;
serde_json::json!(v)
let v: RedisRawValue = redis::cmd("LRANGE").arg(key).arg(0).arg(-1).query_async(con).await.map_err(|e| e.to_string())?;
(redis_array_to_json(v), false)
}
"set" => {
let v: Vec<String> = con.smembers(key).await.map_err(|e| e.to_string())?;
serde_json::json!(v)
let v: RedisRawValue = redis::cmd("SMEMBERS").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
(redis_array_to_json(v), false)
}
"zset" => {
let v: Vec<(String, f64)> = con.zrange_withscores(key, 0, -1).await.map_err(|e| e.to_string())?;
serde_json::json!(v.iter().map(|(m, s)| serde_json::json!({"member": m, "score": s})).collect::<Vec<_>>())
let v: RedisRawValue = redis::cmd("ZRANGE")
.arg(key)
.arg(0)
.arg(-1)
.arg("WITHSCORES")
.query_async(con)
.await
.map_err(|e| e.to_string())?;
(parse_zset_entries(v), false)
}
"hash" => {
let v: Vec<(String, String)> = con.hgetall(key).await.map_err(|e| e.to_string())?;
let map: serde_json::Map<String, serde_json::Value> =
v.into_iter().map(|(k, v)| (k, serde_json::Value::String(v))).collect();
serde_json::Value::Object(map)
let v: RedisRawValue = redis::cmd("HGETALL").arg(key).query_async(con).await.map_err(|e| e.to_string())?;
(parse_hash_entries(v), false)
}
"stream" => get_stream_entries(con, key).await?,
_ => serde_json::Value::Null,
"stream" => (get_stream_entries(con, key).await?, false),
_ => (serde_json::Value::Null, false),
};
Ok(RedisValue { key: key.to_string(), key_type, ttl, value })
Ok(RedisValue {
key_display: redis_key_bytes_to_display(key),
key_raw: redis_key_bytes_to_raw(key),
key_type,
ttl,
value_is_binary,
value,
})
}
async fn get_stream_entries(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
key: &[u8],
) -> Result<serde_json::Value, String> {
let raw: RedisRawValue = redis::cmd("XRANGE")
.arg(key)
@ -168,6 +192,69 @@ async fn get_stream_entries(
Ok(parse_stream_entries(raw))
}
fn parse_scan_keys(raw: RedisRawValue) -> Result<(u64, Vec<Vec<u8>>), String> {
let RedisRawValue::Array(parts) = raw else {
return Err("Invalid Redis SCAN response".to_string());
};
if parts.len() != 2 {
return Err("Invalid Redis SCAN response".to_string());
}
let cursor = redis_value_to_string(parts[0].clone())
.ok_or_else(|| "Invalid Redis SCAN cursor".to_string())?
.parse::<u64>()
.map_err(|_| "Invalid Redis SCAN cursor".to_string())?;
let RedisRawValue::Array(keys) = &parts[1] else {
return Err("Invalid Redis SCAN keys payload".to_string());
};
let mut parsed = Vec::with_capacity(keys.len());
for key in keys {
parsed.push(redis_value_to_bytes(key.clone()).ok_or_else(|| "Invalid Redis key payload".to_string())?);
}
Ok((cursor, parsed))
}
fn parse_hash_entries(raw: RedisRawValue) -> serde_json::Value {
let RedisRawValue::Array(entries) = raw else {
return serde_json::Value::Null;
};
let mut map = serde_json::Map::new();
let mut iter = entries.into_iter();
while let Some(field) = iter.next() {
let Some(value) = iter.next() else {
break;
};
let field = redis_value_to_string(field).unwrap_or_default();
map.insert(field, redis_raw_to_json(value));
}
serde_json::Value::Object(map)
}
fn parse_zset_entries(raw: RedisRawValue) -> serde_json::Value {
let RedisRawValue::Array(entries) = raw else {
return serde_json::Value::Null;
};
let mut rows = Vec::new();
let mut iter = entries.into_iter();
while let Some(member) = iter.next() {
let Some(score) = iter.next() else {
break;
};
rows.push(serde_json::json!({
"member": redis_value_to_string(member).unwrap_or_default(),
"score": redis_value_to_string(score).unwrap_or_default(),
}));
}
serde_json::Value::Array(rows)
}
fn parse_stream_entries(raw: RedisRawValue) -> serde_json::Value {
match raw {
RedisRawValue::Array(entries) => {
@ -209,70 +296,139 @@ fn parse_stream_entry(entry: RedisRawValue) -> Option<serde_json::Value> {
fn redis_value_to_string(value: RedisRawValue) -> Option<String> {
match value {
RedisRawValue::BulkString(bytes) => Some(String::from_utf8_lossy(&bytes).to_string()),
RedisRawValue::BulkString(bytes) => Some(redis_bytes_to_display(&bytes)),
RedisRawValue::SimpleString(value) => Some(value),
RedisRawValue::Int(value) => Some(value.to_string()),
RedisRawValue::Double(value) => Some(value.to_string()),
RedisRawValue::Boolean(value) => Some(value.to_string()),
RedisRawValue::VerbatimString { text, .. } => Some(text),
RedisRawValue::VerbatimString { text, .. } => Some(redis_bytes_to_display(text.as_bytes())),
RedisRawValue::Okay => Some("OK".to_string()),
_ => None,
}
}
fn redis_value_contains_binary(value: &RedisRawValue) -> bool {
match value {
RedisRawValue::BulkString(bytes) => redis_bytes_need_escape(bytes),
RedisRawValue::VerbatimString { text, .. } => redis_bytes_need_escape(text.as_bytes()),
_ => false,
}
}
fn redis_value_to_bytes(value: RedisRawValue) -> Option<Vec<u8>> {
match value {
RedisRawValue::BulkString(bytes) => Some(bytes),
RedisRawValue::SimpleString(value) => Some(value.into_bytes()),
RedisRawValue::Int(value) => Some(value.to_string().into_bytes()),
RedisRawValue::Double(value) => Some(value.to_string().into_bytes()),
RedisRawValue::Boolean(value) => Some(value.to_string().into_bytes()),
RedisRawValue::VerbatimString { text, .. } => Some(text.into_bytes()),
RedisRawValue::Okay => Some(b"OK".to_vec()),
_ => None,
}
}
fn redis_array_to_json(value: RedisRawValue) -> serde_json::Value {
match value {
RedisRawValue::Array(values) => serde_json::Value::Array(values.into_iter().map(redis_raw_to_json).collect()),
other => redis_raw_to_json(other),
}
}
fn redis_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_raw_to_json).collect()),
other => serde_json::Value::String(redis_value_to_string(other).unwrap_or_default()),
}
}
fn redis_bytes_need_escape(bytes: &[u8]) -> bool {
bytes.iter().any(|&byte| !matches!(byte, 0x20..=0x7e) || byte == b'\\')
}
fn redis_bytes_to_display(bytes: &[u8]) -> String {
let mut output = String::new();
for &byte in bytes {
match byte {
b'\\' => output.push_str("\\\\"),
0x20..=0x7e => output.push(byte as char),
_ => output.push_str(&format!("\\x{:02x}", byte)),
}
}
output
}
pub fn redis_key_bytes_to_display(bytes: &[u8]) -> String {
redis_bytes_to_display(bytes)
}
pub fn redis_key_bytes_to_raw(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub fn redis_key_raw_to_bytes(value: &str) -> Result<Vec<u8>, String> {
base64::engine::general_purpose::STANDARD
.decode(value)
.map_err(|e| format!("Invalid Redis key encoding: {e}"))
}
pub async fn set_string(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
key: &[u8],
value: &str,
ttl: Option<i64>,
) -> Result<(), String> {
con.set::<_, _, ()>(key, value).await.map_err(|e| e.to_string())?;
redis::cmd("SET").arg(key).arg(value).query_async::<()>(con).await.map_err(|e| e.to_string())?;
if let Some(t) = ttl {
if t > 0 {
con.expire::<_, ()>(key, t).await.map_err(|e| e.to_string())?;
redis::cmd("EXPIRE").arg(key).arg(t).query_async::<()>(con).await.map_err(|e| e.to_string())?;
}
}
Ok(())
}
pub async fn delete_key(con: &mut redis::aio::MultiplexedConnection, key: &str) -> Result<(), String> {
con.del::<_, ()>(key).await.map_err(|e| e.to_string())
pub async fn delete_key(con: &mut redis::aio::MultiplexedConnection, key: &[u8]) -> Result<(), String> {
redis::cmd("DEL").arg(key).query_async::<()>(con).await.map_err(|e| e.to_string())
}
pub async fn hash_set(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
key: &[u8],
field: &str,
value: &str,
) -> Result<(), String> {
con.hset::<_, _, _, ()>(key, field, value).await.map_err(|e| e.to_string())
redis::cmd("HSET").arg(key).arg(field).arg(value).query_async::<()>(con).await.map_err(|e| e.to_string())
}
pub async fn hash_del(con: &mut redis::aio::MultiplexedConnection, key: &str, field: &str) -> Result<(), String> {
con.hdel::<_, _, ()>(key, field).await.map_err(|e| e.to_string())
pub async fn hash_del(con: &mut redis::aio::MultiplexedConnection, key: &[u8], field: &str) -> Result<(), String> {
redis::cmd("HDEL").arg(key).arg(field).query_async::<()>(con).await.map_err(|e| e.to_string())
}
pub async fn list_push(con: &mut redis::aio::MultiplexedConnection, key: &str, value: &str) -> Result<(), String> {
con.rpush::<_, _, ()>(key, value).await.map_err(|e| e.to_string())
pub async fn list_push(con: &mut redis::aio::MultiplexedConnection, key: &[u8], value: &str) -> Result<(), String> {
redis::cmd("RPUSH").arg(key).arg(value).query_async::<()>(con).await.map_err(|e| e.to_string())
}
pub async fn list_remove(con: &mut redis::aio::MultiplexedConnection, key: &str, index: i64) -> Result<(), String> {
pub async fn list_remove(con: &mut redis::aio::MultiplexedConnection, key: &[u8], index: i64) -> Result<(), String> {
let placeholder = "__DELETED_PLACEHOLDER__";
redis::cmd("LSET").arg(key).arg(index).arg(placeholder).query_async::<()>(con).await.map_err(|e| e.to_string())?;
con.lrem::<_, _, ()>(key, 1, placeholder).await.map_err(|e| e.to_string())
redis::cmd("LREM").arg(key).arg(1).arg(placeholder).query_async::<()>(con).await.map_err(|e| e.to_string())
}
pub async fn set_add(con: &mut redis::aio::MultiplexedConnection, key: &str, member: &str) -> Result<(), String> {
con.sadd::<_, _, ()>(key, member).await.map_err(|e| e.to_string())
pub async fn set_add(con: &mut redis::aio::MultiplexedConnection, key: &[u8], member: &str) -> Result<(), String> {
redis::cmd("SADD").arg(key).arg(member).query_async::<()>(con).await.map_err(|e| e.to_string())
}
pub async fn set_remove(con: &mut redis::aio::MultiplexedConnection, key: &str, member: &str) -> Result<(), String> {
con.srem::<_, _, ()>(key, member).await.map_err(|e| e.to_string())
pub async fn set_remove(con: &mut redis::aio::MultiplexedConnection, key: &[u8], member: &str) -> Result<(), String> {
redis::cmd("SREM").arg(key).arg(member).query_async::<()>(con).await.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::{parse_database_count, parse_stream_entries, RedisRawValue};
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, RedisRawValue,
};
fn bulk(value: &str) -> RedisRawValue {
RedisRawValue::BulkString(value.as_bytes().to_vec())
@ -335,4 +491,44 @@ mod tests {
assert_eq!(parse_database_count(value), Some(32));
}
#[test]
fn formats_binary_keys_like_rdm() {
let bytes = [0xAC, 0xED, 0x00, 0x05, b't', 0x00, b'A', b'\\'];
assert_eq!(redis_key_bytes_to_display(&bytes), "\\xac\\xed\\x00\\x05t\\x00A\\\\");
}
#[test]
fn round_trips_raw_key_transport() {
let bytes = b"\xAC\xED\x00\x05t\x00token";
let encoded = redis_key_bytes_to_raw(bytes);
assert_eq!(redis_key_raw_to_bytes(&encoded).unwrap(), bytes);
}
#[test]
fn parses_scan_response_with_binary_keys() {
let raw = RedisRawValue::Array(vec![
RedisRawValue::BulkString(b"17".to_vec()),
RedisRawValue::Array(vec![
RedisRawValue::BulkString(vec![0xAC, 0xED, 0x00, 0x05, b't']),
RedisRawValue::BulkString(b"plain:key".to_vec()),
]),
]);
let (cursor, keys) = parse_scan_keys(raw).unwrap();
assert_eq!(cursor, 17);
assert_eq!(keys, vec![vec![0xAC, 0xED, 0x00, 0x05, b't'], b"plain:key".to_vec()]);
}
#[test]
fn formats_binary_string_values_like_rdm() {
let raw = RedisRawValue::BulkString(vec![0xAC, 0xED, 0x00, 0x05, b's', b'r']);
let value = redis_raw_to_json(raw);
assert_eq!(value, serde_json::Value::String("\\xac\\xed\\x00\\x05sr".to_string()));
}
}

View File

@ -34,12 +34,23 @@ pub async fn redis_scan_keys_core(
}
pub async fn redis_get_value_core(state: &AppState, connection_id: &str, key: &str) -> Result<RedisValue, String> {
redis_get_value_in_db_core(state, connection_id, 0, key).await
}
pub async fn redis_get_value_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
) -> Result<RedisValue, String> {
let connections = state.connections.lock().await;
let pool = connections.get(connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::get_value(&mut con, key).await
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::get_value(&mut con, &key).await
}
_ => Err("Not a Redis connection".to_string()),
}
@ -51,25 +62,49 @@ pub async fn redis_set_string_core(
key: &str,
value: &str,
ttl: Option<i64>,
) -> Result<(), String> {
redis_set_string_in_db_core(state, connection_id, 0, key, value, ttl).await
}
pub async fn redis_set_string_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
value: &str,
ttl: Option<i64>,
) -> Result<(), String> {
let connections = state.connections.lock().await;
let pool = connections.get(connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::set_string(&mut con, key, value, ttl).await
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::set_string(&mut con, &key, value, ttl).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_delete_key_core(state: &AppState, connection_id: &str, key: &str) -> Result<(), String> {
redis_delete_key_in_db_core(state, connection_id, 0, key).await
}
pub async fn redis_delete_key_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
) -> Result<(), String> {
let connections = state.connections.lock().await;
let pool = connections.get(connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::delete_key(&mut con, key).await
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::delete_key(&mut con, &key).await
}
_ => Err("Not a Redis connection".to_string()),
}
@ -81,26 +116,72 @@ pub async fn redis_hash_set_core(
key: &str,
field: &str,
value: &str,
) -> Result<(), String> {
redis_hash_set_in_db_core(state, connection_id, 0, key, field, value).await
}
pub async fn redis_hash_set_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
field: &str,
value: &str,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::hash_set(&mut *con.lock().await, key, field, value).await,
PoolKind::Redis(con) => {
let mut con = con.lock().await;
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::hash_set(&mut con, &key, field, value).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_hash_del_core(state: &AppState, connection_id: &str, key: &str, field: &str) -> Result<(), String> {
redis_hash_del_in_db_core(state, connection_id, 0, key, field).await
}
pub async fn redis_hash_del_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
field: &str,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::hash_del(&mut *con.lock().await, key, field).await,
PoolKind::Redis(con) => {
let mut con = con.lock().await;
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::hash_del(&mut con, &key, field).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_list_push_core(state: &AppState, connection_id: &str, key: &str, value: &str) -> Result<(), String> {
redis_list_push_in_db_core(state, connection_id, 0, key, value).await
}
pub async fn redis_list_push_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
value: &str,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::list_push(&mut *con.lock().await, key, value).await,
PoolKind::Redis(con) => {
let mut con = con.lock().await;
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::list_push(&mut con, &key, value).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
@ -110,18 +191,48 @@ pub async fn redis_list_remove_core(
connection_id: &str,
key: &str,
index: i64,
) -> Result<(), String> {
redis_list_remove_in_db_core(state, connection_id, 0, key, index).await
}
pub async fn redis_list_remove_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
index: i64,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::list_remove(&mut *con.lock().await, key, index).await,
PoolKind::Redis(con) => {
let mut con = con.lock().await;
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::list_remove(&mut con, &key, index).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_set_add_core(state: &AppState, connection_id: &str, key: &str, member: &str) -> Result<(), String> {
redis_set_add_in_db_core(state, connection_id, 0, key, member).await
}
pub async fn redis_set_add_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
member: &str,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::set_add(&mut *con.lock().await, key, member).await,
PoolKind::Redis(con) => {
let mut con = con.lock().await;
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::set_add(&mut con, &key, member).await
}
_ => Err("Not a Redis connection".to_string()),
}
}
@ -131,10 +242,25 @@ pub async fn redis_set_remove_core(
connection_id: &str,
key: &str,
member: &str,
) -> Result<(), String> {
redis_set_remove_in_db_core(state, connection_id, 0, key, member).await
}
pub async fn redis_set_remove_in_db_core(
state: &AppState,
connection_id: &str,
db: u32,
key_raw: &str,
member: &str,
) -> Result<(), String> {
let connections = state.connections.lock().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(con) => redis_driver::set_remove(&mut *con.lock().await, key, member).await,
PoolKind::Redis(con) => {
let mut con = con.lock().await;
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
redis_driver::select_db(&mut con, db).await?;
redis_driver::set_remove(&mut con, &key, member).await
}
_ => Err("Not a Redis connection".to_string()),
}
}

View File

@ -25,88 +25,97 @@ pub async fn redis_scan_keys(
pub async fn redis_get_value(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
) -> Result<RedisValue, String> {
dbx_core::redis_ops::redis_get_value_core(&state, &connection_id, &key).await
dbx_core::redis_ops::redis_get_value_in_db_core(&state, &connection_id, db, &key_raw).await
}
#[tauri::command]
pub async fn redis_set_string(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
value: String,
ttl: Option<i64>,
) -> Result<(), String> {
dbx_core::redis_ops::redis_set_string_core(&state, &connection_id, &key, &value, ttl).await
dbx_core::redis_ops::redis_set_string_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await
}
#[tauri::command]
pub async fn redis_delete_key(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
) -> Result<(), String> {
dbx_core::redis_ops::redis_delete_key_core(&state, &connection_id, &key).await
dbx_core::redis_ops::redis_delete_key_in_db_core(&state, &connection_id, db, &key_raw).await
}
#[tauri::command]
pub async fn redis_hash_set(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
field: String,
value: String,
) -> Result<(), String> {
dbx_core::redis_ops::redis_hash_set_core(&state, &connection_id, &key, &field, &value).await
dbx_core::redis_ops::redis_hash_set_in_db_core(&state, &connection_id, db, &key_raw, &field, &value).await
}
#[tauri::command]
pub async fn redis_hash_del(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
field: String,
) -> Result<(), String> {
dbx_core::redis_ops::redis_hash_del_core(&state, &connection_id, &key, &field).await
dbx_core::redis_ops::redis_hash_del_in_db_core(&state, &connection_id, db, &key_raw, &field).await
}
#[tauri::command]
pub async fn redis_list_push(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
value: String,
) -> Result<(), String> {
dbx_core::redis_ops::redis_list_push_core(&state, &connection_id, &key, &value).await
dbx_core::redis_ops::redis_list_push_in_db_core(&state, &connection_id, db, &key_raw, &value).await
}
#[tauri::command]
pub async fn redis_list_remove(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
index: i64,
) -> Result<(), String> {
dbx_core::redis_ops::redis_list_remove_core(&state, &connection_id, &key, index).await
dbx_core::redis_ops::redis_list_remove_in_db_core(&state, &connection_id, db, &key_raw, index).await
}
#[tauri::command]
pub async fn redis_set_add(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
member: String,
) -> Result<(), String> {
dbx_core::redis_ops::redis_set_add_core(&state, &connection_id, &key, &member).await
dbx_core::redis_ops::redis_set_add_in_db_core(&state, &connection_id, db, &key_raw, &member).await
}
#[tauri::command]
pub async fn redis_set_remove(
state: State<'_, Arc<AppState>>,
connection_id: String,
key: String,
db: u32,
key_raw: String,
member: String,
) -> Result<(), String> {
dbx_core::redis_ops::redis_set_remove_core(&state, &connection_id, &key, &member).await
dbx_core::redis_ops::redis_set_remove_in_db_core(&state, &connection_id, db, &key_raw, &member).await
}

View File

@ -27,14 +27,16 @@ pub struct RedisScanRequest {
#[serde(rename_all = "camelCase")]
pub struct RedisKeyRequest {
pub connection_id: String,
pub key: String,
pub db: u32,
pub key_raw: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedisSetStringRequest {
pub connection_id: String,
pub key: String,
pub db: u32,
pub key_raw: String,
pub value: String,
pub ttl: Option<i64>,
}
@ -43,7 +45,8 @@ pub struct RedisSetStringRequest {
#[serde(rename_all = "camelCase")]
pub struct RedisHashRequest {
pub connection_id: String,
pub key: String,
pub db: u32,
pub key_raw: String,
pub field: String,
pub value: Option<String>,
}
@ -52,7 +55,8 @@ pub struct RedisHashRequest {
#[serde(rename_all = "camelCase")]
pub struct RedisListRequest {
pub connection_id: String,
pub key: String,
pub db: u32,
pub key_raw: String,
pub value: Option<String>,
pub index: Option<i64>,
}
@ -61,7 +65,8 @@ pub struct RedisListRequest {
#[serde(rename_all = "camelCase")]
pub struct RedisSetRequest {
pub connection_id: String,
pub key: String,
pub db: u32,
pub key_raw: String,
pub member: String,
}
@ -95,8 +100,9 @@ pub async fn get_value(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisKeyRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let result =
dbx_core::redis_ops::redis_get_value_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?;
let result = dbx_core::redis_ops::redis_get_value_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw)
.await
.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
@ -104,9 +110,16 @@ pub async fn set_string(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisSetStringRequest>,
) -> Result<Json<()>, AppError> {
dbx_core::redis_ops::redis_set_string_core(&state.app, &req.connection_id, &req.key, &req.value, req.ttl)
.await
.map_err(AppError)?;
dbx_core::redis_ops::redis_set_string_in_db_core(
&state.app,
&req.connection_id,
req.db,
&req.key_raw,
&req.value,
req.ttl,
)
.await
.map_err(AppError)?;
Ok(Json(()))
}
@ -114,7 +127,9 @@ pub async fn delete_key(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisKeyRequest>,
) -> Result<Json<()>, AppError> {
dbx_core::redis_ops::redis_delete_key_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?;
dbx_core::redis_ops::redis_delete_key_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw)
.await
.map_err(AppError)?;
Ok(Json(()))
}
@ -123,7 +138,14 @@ pub async fn hash_set(
Json(req): Json<RedisHashRequest>,
) -> Result<Json<()>, AppError> {
let value = req.value.as_deref().unwrap_or("");
dbx_core::redis_ops::redis_hash_set_core(&state.app, &req.connection_id, &req.key, &req.field, value)
dbx_core::redis_ops::redis_hash_set_in_db_core(
&state.app,
&req.connection_id,
req.db,
&req.key_raw,
&req.field,
value,
)
.await
.map_err(AppError)?;
Ok(Json(()))
@ -133,7 +155,7 @@ pub async fn hash_del(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisHashRequest>,
) -> Result<Json<()>, AppError> {
dbx_core::redis_ops::redis_hash_del_core(&state.app, &req.connection_id, &req.key, &req.field)
dbx_core::redis_ops::redis_hash_del_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.field)
.await
.map_err(AppError)?;
Ok(Json(()))
@ -144,7 +166,7 @@ pub async fn list_push(
Json(req): Json<RedisListRequest>,
) -> Result<Json<()>, AppError> {
let value = req.value.as_deref().unwrap_or("");
dbx_core::redis_ops::redis_list_push_core(&state.app, &req.connection_id, &req.key, value)
dbx_core::redis_ops::redis_list_push_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, value)
.await
.map_err(AppError)?;
Ok(Json(()))
@ -155,7 +177,7 @@ pub async fn list_remove(
Json(req): Json<RedisListRequest>,
) -> Result<Json<()>, AppError> {
let index = req.index.unwrap_or(0);
dbx_core::redis_ops::redis_list_remove_core(&state.app, &req.connection_id, &req.key, index)
dbx_core::redis_ops::redis_list_remove_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, index)
.await
.map_err(AppError)?;
Ok(Json(()))
@ -165,7 +187,7 @@ pub async fn set_add(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisSetRequest>,
) -> Result<Json<()>, AppError> {
dbx_core::redis_ops::redis_set_add_core(&state.app, &req.connection_id, &req.key, &req.member)
dbx_core::redis_ops::redis_set_add_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.member)
.await
.map_err(AppError)?;
Ok(Json(()))
@ -175,7 +197,13 @@ pub async fn set_remove(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisSetRequest>,
) -> Result<Json<()>, AppError> {
dbx_core::redis_ops::redis_set_remove_core(&state.app, &req.connection_id, &req.key, &req.member)
dbx_core::redis_ops::redis_set_remove_in_db_core(
&state.app,
&req.connection_id,
req.db,
&req.key_raw,
&req.member,
)
.await
.map_err(AppError)?;
Ok(Json(()))

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { computed, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Search, RefreshCw, Key, Loader2 } from "lucide-vue-next";
import { Search, RefreshCw, Key, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -10,6 +10,12 @@ import { Badge } from "@/components/ui/badge";
import RedisValueViewer from "./RedisValueViewer.vue";
import * as api from "@/lib/api";
import type { RedisKeyInfo } from "@/lib/api";
import {
buildRedisKeyTree,
collectExpandedGroupIds,
flattenVisibleRedisKeyTree,
type RedisKeyTreeNode,
} from "@/lib/redisKeyTree";
const { t } = useI18n();
@ -18,23 +24,51 @@ const props = defineProps<{
db: number;
}>();
const keys = ref<RedisKeyInfo[]>([]);
const flatKeys = ref<RedisKeyInfo[]>([]);
const treeKeys = ref<RedisKeyTreeNode[]>([]);
const loading = ref(false);
const searchPattern = ref("*");
const selectedKey = ref<string | null>(null);
const selectedKeyRaw = ref<string | null>(null);
const cursor = ref(0);
const hasMore = ref(false);
const expandedGroupIds = ref<Set<string>>(new Set());
const PAGE_SIZE = 200;
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 visibleRows = computed(() => flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value));
function rebuildTree(expandAll = false) {
const nextTree = buildRedisKeyTree(flatKeys.value, props.db);
treeKeys.value = nextTree;
const nextExpanded = new Set<string>();
const availableExpanded = collectExpandedGroupIds(nextTree);
if (expandAll) {
for (const id of availableExpanded) nextExpanded.add(id);
} else {
for (const id of expandedGroupIds.value) {
if (availableExpanded.has(id)) nextExpanded.add(id);
}
}
expandedGroupIds.value = nextExpanded;
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
}
async function loadKeys() {
loading.value = true;
try {
const result = await api.redisScanKeys(props.connectionId, props.db, 0, searchPattern.value, PAGE_SIZE);
keys.value = result.keys;
const result = await api.redisScanKeys(props.connectionId, props.db, 0, effectivePattern.value, PAGE_SIZE);
flatKeys.value = result.keys;
cursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
selectedKey.value = null;
selectedKeyRaw.value = null;
rebuildTree(isSearchMode.value);
} finally {
loading.value = false;
}
@ -45,25 +79,44 @@ async function loadMoreKeys() {
loading.value = true;
try {
const result = await api.redisScanKeys(props.connectionId, props.db, cursor.value, searchPattern.value, PAGE_SIZE);
const existingKeys = new Set(keys.value.map((k) => k.key));
keys.value = [...keys.value, ...result.keys.filter((k) => !existingKeys.has(k.key))];
const result = await api.redisScanKeys(
props.connectionId,
props.db,
cursor.value,
effectivePattern.value,
PAGE_SIZE,
);
const existingKeys = new Set(flatKeys.value.map((key) => key.key_raw));
flatKeys.value = [...flatKeys.value, ...result.keys.filter((key) => !existingKeys.has(key.key_raw))];
cursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
rebuildTree(isSearchMode.value);
} finally {
loading.value = false;
}
}
function selectKey(key: string) {
selectedKey.value = key;
function toggleGroup(groupId: string) {
const next = new Set(expandedGroupIds.value);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
expandedGroupIds.value = next;
}
function onRowClick(node: RedisKeyTreeNode) {
if (node.kind === "group") {
toggleGroup(node.id);
return;
}
selectedKeyRaw.value = node.keyRaw;
}
function onKeyDeleted() {
if (selectedKey.value) {
keys.value = keys.value.filter((k) => k.key !== selectedKey.value);
selectedKey.value = null;
}
if (!selectedKeyRaw.value) return;
flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== selectedKeyRaw.value);
selectedKeyRaw.value = null;
rebuildTree(false);
}
function typeColor(type: string): string {
@ -110,33 +163,56 @@ onMounted(loadKeys);
<!-- Key count -->
<div class="h-9 flex items-center px-3 text-xs text-muted-foreground border-b shrink-0">
{{ loading && keys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: keys.length }) }}
{{ loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length }) }}
</div>
<!-- Key list -->
<!-- Key tree -->
<div class="flex-1 overflow-y-auto">
<div
v-for="k in keys"
:key="k.key"
v-for="row in visibleRows"
:key="row.node.id"
class="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer hover:bg-accent/50 border-b border-border/50"
:class="{ 'bg-accent': selectedKey === k.key }"
@click="selectKey(k.key)"
:class="{ 'bg-accent': row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw }"
:style="{ paddingLeft: `${12 + row.depth * 18}px` }"
:title="row.node.kind === 'leaf' ? row.node.fullKeyDisplay : row.node.pathSegments.join(':')"
@click="onRowClick(row.node)"
>
<Key class="w-3 h-3 shrink-0" :class="typeColor(k.key_type)" />
<span class="truncate flex-1 font-mono">{{ k.key }}</span>
<Badge variant="outline" class="text-[10px] px-1 py-0 shrink-0">{{ k.key_type }}</Badge>
<template v-if="row.node.kind === 'group'">
<component
:is="expandedGroupIds.has(row.node.id) ? ChevronDown : ChevronRight"
class="w-3 h-3 shrink-0 text-muted-foreground"
/>
<component
:is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed"
class="w-3 h-3 shrink-0 text-amber-500"
/>
<span class="truncate flex-1 font-mono">{{ row.node.label }}</span>
</template>
<template v-else>
<span class="w-3 h-3 shrink-0" />
<Key class="w-3 h-3 shrink-0" :class="typeColor(row.node.keyType)" />
<span class="truncate font-mono">{{ row.node.label || row.node.fullKeyDisplay }}</span>
<span
v-if="row.node.label !== row.node.fullKeyDisplay"
class="truncate flex-1 text-[10px] text-muted-foreground font-mono"
>
{{ row.node.fullKeyDisplay }}
</span>
<span v-else class="flex-1" />
<Badge variant="outline" class="text-[10px] px-1 py-0 shrink-0">{{ row.node.keyType }}</Badge>
</template>
</div>
<div v-if="keys.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
<div v-if="flatKeys.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t("redis.noKeys") }}
</div>
<div
v-if="loading && keys.length === 0"
v-if="loading && flatKeys.length === 0"
class="px-3 py-8 flex items-center justify-center gap-2 text-muted-foreground text-xs"
>
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>{{ t("redis.loadingKeys") }}</span>
</div>
<div v-if="hasMore || (loading && keys.length > 0)" class="p-2">
<div v-if="hasMore || (loading && flatKeys.length > 0)" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loading" @click="loadMoreKeys">
<Loader2 v-if="loading" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
@ -151,9 +227,11 @@ onMounted(loadKeys);
<div class="h-full min-w-0">
<RedisValueViewer
v-if="selectedKey"
:key="selectedKey"
:key="selectedKey.key_raw"
:connection-id="connectionId"
:key-name="selectedKey"
:db="db"
:key-display="selectedKey.key_display"
:key-raw="selectedKey.key_raw"
@deleted="onKeyDeleted"
/>
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">

View File

@ -13,7 +13,9 @@ const { t } = useI18n();
const props = defineProps<{
connectionId: string;
keyName: string;
db: number;
keyDisplay: string;
keyRaw: string;
}>();
const emit = defineEmits<{ deleted: [] }>();
@ -37,18 +39,20 @@ const pendingDelete = ref<PendingDelete | null>(null);
const deleteDetails = computed(() => {
const pending = pendingDelete.value;
if (!pending) return "";
if (pending.kind === "key") return t("dangerDialog.redisKeyDetails", { key: props.keyName });
if (pending.kind === "key") return t("dangerDialog.redisKeyDetails", { key: props.keyDisplay });
if (pending.kind === "hash")
return t("dangerDialog.redisHashFieldDetails", { key: props.keyName, field: pending.field });
return t("dangerDialog.redisHashFieldDetails", { key: props.keyDisplay, field: pending.field });
if (pending.kind === "list")
return t("dangerDialog.redisListItemDetails", { key: props.keyName, index: pending.index });
return t("dangerDialog.redisSetMemberDetails", { key: props.keyName, member: pending.member });
return t("dangerDialog.redisListItemDetails", { key: props.keyDisplay, index: pending.index });
return t("dangerDialog.redisSetMemberDetails", { key: props.keyDisplay, member: pending.member });
});
const isBinaryStringValue = computed(() => data.value?.key_type === "string" && data.value?.value_is_binary);
async function load() {
loading.value = true;
try {
data.value = await api.redisGetValue(props.connectionId, props.keyName);
data.value = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
if (data.value.key_type === "string") {
editValue.value = String(data.value.value);
}
@ -58,13 +62,20 @@ async function load() {
}
async function saveString() {
await api.redisSetString(props.connectionId, props.keyName, editValue.value);
if (isBinaryStringValue.value) return;
await api.redisSetString(props.connectionId, props.db, props.keyRaw, editValue.value);
isEditing.value = false;
await load();
}
function handleStringInput() {
if (!isBinaryStringValue.value) {
isEditing.value = true;
}
}
async function applyDeleteKey() {
await api.redisDeleteKey(props.connectionId, props.keyName);
await api.redisDeleteKey(props.connectionId, props.db, props.keyRaw);
emit("deleted");
}
@ -82,13 +93,13 @@ function copyValue() {
// Hash
async function hashSet() {
if (!newField.value) return;
await api.redisHashSet(props.connectionId, props.keyName, newField.value, newValue.value);
await api.redisHashSet(props.connectionId, props.db, props.keyRaw, newField.value, newValue.value);
newField.value = "";
newValue.value = "";
await load();
}
async function applyHashDel(field: string) {
await api.redisHashDel(props.connectionId, props.keyName, field);
await api.redisHashDel(props.connectionId, props.db, props.keyRaw, field);
await load();
}
function requestHashDel(field: string) {
@ -99,12 +110,12 @@ function requestHashDel(field: string) {
// List
async function listPush() {
if (!newValue.value) return;
await api.redisListPush(props.connectionId, props.keyName, newValue.value);
await api.redisListPush(props.connectionId, props.db, props.keyRaw, newValue.value);
newValue.value = "";
await load();
}
async function applyListRemove(index: number) {
await api.redisListRemove(props.connectionId, props.keyName, index);
await api.redisListRemove(props.connectionId, props.db, props.keyRaw, index);
await load();
}
function requestListRemove(index: number) {
@ -115,12 +126,12 @@ function requestListRemove(index: number) {
// Set
async function setAdd() {
if (!newValue.value) return;
await api.redisSetAdd(props.connectionId, props.keyName, newValue.value);
await api.redisSetAdd(props.connectionId, props.db, props.keyRaw, newValue.value);
newValue.value = "";
await load();
}
async function applySetRemove(member: string) {
await api.redisSetRemove(props.connectionId, props.keyName, member);
await api.redisSetRemove(props.connectionId, props.db, props.keyRaw, member);
await load();
}
function requestSetRemove(member: string) {
@ -155,7 +166,7 @@ onMounted(load);
<template v-else-if="data">
<!-- Header -->
<div class="flex items-center gap-2 px-4 py-2 border-b bg-muted/30 shrink-0">
<span class="font-mono text-sm font-medium truncate">{{ data.key }}</span>
<span class="font-mono text-sm font-medium truncate">{{ data.key_display }}</span>
<Badge variant="secondary" class="text-xs">{{ data.key_type }}</Badge>
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs">TTL: {{ data.ttl }}s</Badge>
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs opacity-50">{{
@ -174,8 +185,12 @@ onMounted(load);
<textarea
v-model="editValue"
class="flex-1 p-4 font-mono text-sm bg-background resize-none outline-none"
@input="isEditing = true"
:readonly="isBinaryStringValue"
@input="handleStringInput"
/>
<div v-if="isBinaryStringValue" class="px-4 py-2 border-t text-xs text-muted-foreground shrink-0">
二进制字符串按转义文本只读展示当前不支持直接编辑原始字节值
</div>
<div v-if="isEditing" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
<Button
variant="ghost"

View File

@ -450,40 +450,52 @@ export async function redisScanKeys(
return post("/api/redis/scan-keys", { connectionId, db, cursor, pattern, count });
}
export async function redisGetValue(connectionId: string, key: string): Promise<RedisValue> {
return post("/api/redis/get-value", { connectionId, key });
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
return post("/api/redis/get-value", { connectionId, db, keyRaw });
}
export async function redisSetString(connectionId: string, key: string, value: string, ttl?: number): Promise<void> {
return post("/api/redis/set-string", { connectionId, key, value, ttl });
export async function redisSetString(
connectionId: string,
db: number,
keyRaw: string,
value: string,
ttl?: number,
): Promise<void> {
return post("/api/redis/set-string", { connectionId, db, keyRaw, value, ttl });
}
export async function redisDeleteKey(connectionId: string, key: string): Promise<void> {
return post("/api/redis/delete-key", { connectionId, key });
export async function redisDeleteKey(connectionId: string, db: number, keyRaw: string): Promise<void> {
return post("/api/redis/delete-key", { connectionId, db, keyRaw });
}
export async function redisHashSet(connectionId: string, key: string, field: string, value: string): Promise<void> {
return post("/api/redis/hash-set", { connectionId, key, field, value });
export async function redisHashSet(
connectionId: string,
db: number,
keyRaw: string,
field: string,
value: string,
): Promise<void> {
return post("/api/redis/hash-set", { connectionId, db, keyRaw, field, value });
}
export async function redisHashDel(connectionId: string, key: string, field: string): Promise<void> {
return post("/api/redis/hash-del", { connectionId, key, field });
export async function redisHashDel(connectionId: string, db: number, keyRaw: string, field: string): Promise<void> {
return post("/api/redis/hash-del", { connectionId, db, keyRaw, field });
}
export async function redisListPush(connectionId: string, key: string, value: string): Promise<void> {
return post("/api/redis/list-push", { connectionId, key, value });
export async function redisListPush(connectionId: string, db: number, keyRaw: string, value: string): Promise<void> {
return post("/api/redis/list-push", { connectionId, db, keyRaw, value });
}
export async function redisListRemove(connectionId: string, key: string, index: number): Promise<void> {
return post("/api/redis/list-remove", { connectionId, key, index });
export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise<void> {
return post("/api/redis/list-remove", { connectionId, db, keyRaw, index });
}
export async function redisSetAdd(connectionId: string, key: string, member: string): Promise<void> {
return post("/api/redis/set-add", { connectionId, key, member });
export async function redisSetAdd(connectionId: string, db: number, keyRaw: string, member: string): Promise<void> {
return post("/api/redis/set-add", { connectionId, db, keyRaw, member });
}
export async function redisSetRemove(connectionId: string, key: string, member: string): Promise<void> {
return post("/api/redis/set-remove", { connectionId, key, member });
export async function redisSetRemove(connectionId: string, db: number, keyRaw: string, member: string): Promise<void> {
return post("/api/redis/set-remove", { connectionId, db, keyRaw, member });
}
// ---------------------------------------------------------------------------

141
src/lib/redisKeyTree.ts Normal file
View File

@ -0,0 +1,141 @@
import type { RedisKeyInfo } from "./api";
export interface RedisKeyTreeLeafNode {
kind: "leaf";
id: string;
label: string;
fullKeyDisplay: string;
keyRaw: string;
db: number;
keyType: string;
ttl: number;
pathSegments: string[];
}
export interface RedisKeyTreeGroupNode {
kind: "group";
id: string;
label: string;
pathSegments: string[];
children: RedisKeyTreeNode[];
}
export type RedisKeyTreeNode = RedisKeyTreeLeafNode | RedisKeyTreeGroupNode;
export interface RedisKeyTreeRow {
node: RedisKeyTreeNode;
depth: number;
}
function buildGroupId(db: number, pathSegments: string[]): string {
return `group:${db}:${pathSegments.join("\u0000")}`;
}
function buildLeafId(db: number, keyRaw: string): string {
return `leaf:${db}:${keyRaw}`;
}
function compareRedisTreeNodes(a: RedisKeyTreeNode, b: RedisKeyTreeNode): number {
if (a.kind !== b.kind) return a.kind === "group" ? -1 : 1;
return a.label.localeCompare(b.label);
}
function sortRedisTreeNodes(nodes: RedisKeyTreeNode[]): RedisKeyTreeNode[] {
return [...nodes].sort(compareRedisTreeNodes).map((node) =>
node.kind === "group"
? {
...node,
children: sortRedisTreeNodes(node.children),
}
: node,
);
}
export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number): RedisKeyTreeNode[] {
const root: RedisKeyTreeNode[] = [];
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
for (const key of keys) {
const pathSegments = key.key_display.split(":");
if (pathSegments.length === 1) {
root.push({
kind: "leaf",
id: buildLeafId(db, key.key_raw),
label: pathSegments[0],
fullKeyDisplay: key.key_display,
keyRaw: key.key_raw,
db,
keyType: key.key_type,
ttl: key.ttl,
pathSegments,
});
continue;
}
let currentLevel = root;
const groupSegments: string[] = [];
for (const segment of pathSegments.slice(0, -1)) {
groupSegments.push(segment);
const groupId = buildGroupId(db, groupSegments);
let group = groupMap.get(groupId);
if (!group) {
group = {
kind: "group",
id: groupId,
label: segment,
pathSegments: [...groupSegments],
children: [],
};
groupMap.set(groupId, group);
currentLevel.push(group);
}
currentLevel = group.children;
}
currentLevel.push({
kind: "leaf",
id: buildLeafId(db, key.key_raw),
label: pathSegments[pathSegments.length - 1],
fullKeyDisplay: key.key_display,
keyRaw: key.key_raw,
db,
keyType: key.key_type,
ttl: key.ttl,
pathSegments,
});
}
return sortRedisTreeNodes(root);
}
export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set<string> {
const ids = new Set<string>();
const visit = (entries: RedisKeyTreeNode[]) => {
for (const node of entries) {
if (node.kind !== "group") continue;
ids.add(node.id);
visit(node.children);
}
};
visit(nodes);
return ids;
}
export function flattenVisibleRedisKeyTree(
nodes: RedisKeyTreeNode[],
expandedGroupIds: ReadonlySet<string>,
depth = 0,
): RedisKeyTreeRow[] {
const rows: RedisKeyTreeRow[] = [];
for (const node of nodes) {
rows.push({ node, depth });
if (node.kind === "group" && expandedGroupIds.has(node.id)) {
rows.push(...flattenVisibleRedisKeyTree(node.children, expandedGroupIds, depth + 1));
}
}
return rows;
}

View File

@ -258,15 +258,18 @@ export async function getAppVersion(): Promise<string> {
// --- Redis ---
export interface RedisKeyInfo {
key: string;
key_display: string;
key_raw: string;
key_type: string;
ttl: number;
}
export interface RedisValue {
key: string;
key_display: string;
key_raw: string;
key_type: string;
ttl: number;
value_is_binary: boolean;
value: any;
}
@ -289,40 +292,52 @@ export async function redisScanKeys(
return invoke("redis_scan_keys", { connectionId, db, cursor, pattern, count });
}
export async function redisGetValue(connectionId: string, key: string): Promise<RedisValue> {
return invoke("redis_get_value", { connectionId, key });
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
return invoke("redis_get_value", { connectionId, db, keyRaw });
}
export async function redisSetString(connectionId: string, key: string, value: string, ttl?: number): Promise<void> {
return invoke("redis_set_string", { connectionId, key, value, ttl });
export async function redisSetString(
connectionId: string,
db: number,
keyRaw: string,
value: string,
ttl?: number,
): Promise<void> {
return invoke("redis_set_string", { connectionId, db, keyRaw, value, ttl });
}
export async function redisDeleteKey(connectionId: string, key: string): Promise<void> {
return invoke("redis_delete_key", { connectionId, key });
export async function redisDeleteKey(connectionId: string, db: number, keyRaw: string): Promise<void> {
return invoke("redis_delete_key", { connectionId, db, keyRaw });
}
export async function redisHashSet(connectionId: string, key: string, field: string, value: string): Promise<void> {
return invoke("redis_hash_set", { connectionId, key, field, value });
export async function redisHashSet(
connectionId: string,
db: number,
keyRaw: string,
field: string,
value: string,
): Promise<void> {
return invoke("redis_hash_set", { connectionId, db, keyRaw, field, value });
}
export async function redisHashDel(connectionId: string, key: string, field: string): Promise<void> {
return invoke("redis_hash_del", { connectionId, key, field });
export async function redisHashDel(connectionId: string, db: number, keyRaw: string, field: string): Promise<void> {
return invoke("redis_hash_del", { connectionId, db, keyRaw, field });
}
export async function redisListPush(connectionId: string, key: string, value: string): Promise<void> {
return invoke("redis_list_push", { connectionId, key, value });
export async function redisListPush(connectionId: string, db: number, keyRaw: string, value: string): Promise<void> {
return invoke("redis_list_push", { connectionId, db, keyRaw, value });
}
export async function redisListRemove(connectionId: string, key: string, index: number): Promise<void> {
return invoke("redis_list_remove", { connectionId, key, index });
export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise<void> {
return invoke("redis_list_remove", { connectionId, db, keyRaw, index });
}
export async function redisSetAdd(connectionId: string, key: string, member: string): Promise<void> {
return invoke("redis_set_add", { connectionId, key, member });
export async function redisSetAdd(connectionId: string, db: number, keyRaw: string, member: string): Promise<void> {
return invoke("redis_set_add", { connectionId, db, keyRaw, member });
}
export async function redisSetRemove(connectionId: string, key: string, member: string): Promise<void> {
return invoke("redis_set_remove", { connectionId, key, member });
export async function redisSetRemove(connectionId: string, db: number, keyRaw: string, member: string): Promise<void> {
return invoke("redis_set_remove", { connectionId, db, keyRaw, member });
}
// --- MongoDB ---

View File

@ -0,0 +1,82 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildRedisKeyTree,
collectExpandedGroupIds,
flattenVisibleRedisKeyTree,
type RedisKeyTreeNode,
} from "../src/lib/redisKeyTree.ts";
import type { RedisKeyInfo } from "../src/lib/api.ts";
function makeKey(key_display: string, key_raw: string, key_type = "string", ttl = -1): RedisKeyInfo {
return { key_display, key_raw, key_type, ttl };
}
function leafLabels(nodes: RedisKeyTreeNode[]): string[] {
return nodes.filter((node) => node.kind === "leaf").map((node) => node.label);
}
test("buildRedisKeyTree groups colon-delimited keys by segment", () => {
const tree = buildRedisKeyTree(
[makeKey("a:b:c", "k1"), makeKey("a:b:d", "k2"), makeKey("a:e", "k3"), makeKey("x", "k4")],
0,
);
assert.equal(tree.length, 2);
assert.equal(tree[0]?.kind, "group");
assert.equal(tree[1]?.kind, "leaf");
assert.equal(tree[1]?.kind === "leaf" ? tree[1].fullKeyDisplay : "", "x");
const aGroup = tree[0];
assert.equal(aGroup?.kind, "group");
if (aGroup?.kind !== "group") return;
assert.deepEqual(aGroup.pathSegments, ["a"]);
assert.deepEqual(leafLabels(aGroup.children), ["e"]);
const bGroup = aGroup.children.find((node) => node.kind === "group" && node.label === "b");
assert.ok(bGroup);
if (!bGroup || bGroup.kind !== "group") return;
assert.deepEqual(
bGroup.children.map((node) => (node.kind === "leaf" ? node.fullKeyDisplay : node.label)),
["a:b:c", "a:b:d"],
);
});
test("buildRedisKeyTree preserves binary prefix segments from display text", () => {
const tree = buildRedisKeyTree([makeKey("\\xac\\xed\\x00\\x05t\\x00token:work:app", "k1")], 2);
assert.equal(tree.length, 1);
const root = tree[0];
assert.equal(root?.kind, "group");
if (!root || root.kind !== "group") return;
assert.equal(root.label, "\\xac\\xed\\x00\\x05t\\x00token");
const work = root.children[0];
assert.equal(work?.kind, "group");
if (!work || work.kind !== "group") return;
assert.equal(work.label, "work");
const appLeaf = work.children[0];
assert.equal(appLeaf?.kind, "leaf");
if (!appLeaf || appLeaf.kind !== "leaf") return;
assert.equal(appLeaf.label, "app");
assert.equal(appLeaf.fullKeyDisplay, "\\xac\\xed\\x00\\x05t\\x00token:work:app");
});
test("collectExpandedGroupIds and flattenVisibleRedisKeyTree expand all search paths", () => {
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1"), makeKey("user:settings", "k2")], 0);
const expanded = collectExpandedGroupIds(tree);
const rows = flattenVisibleRedisKeyTree(tree, expanded);
assert.deepEqual(
rows.map(({ node, depth }) => `${depth}:${node.kind}:${node.label}`),
[
"0:group:user",
"1:group:profile",
"2:leaf:name",
"1:leaf:settings",
],
);
});