mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 00:19:35 +00:00
feat(redis): add bulk operations and command runner
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user