feat(redis): add slowlog panel and integrate with backend APIs

* feat(redis): add slowlog panel and integrate with backend APIs

* fix(redis):Slowlog Panel Compatibility Issues
This commit is contained in:
dalew 2026-06-20 21:47:14 +08:00 committed by GitHub
parent dbb3915380
commit f71406c1c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 558 additions and 3 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio } from "@lucide/vue";
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock } from "@lucide/vue";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import { Splitpanes, Pane } from "splitpanes";
@ -16,6 +16,7 @@ import { Switch } from "@/components/ui/switch";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import RedisValueViewer from "./RedisValueViewer.vue";
import RedisPubSubPanel from "./RedisPubSubPanel.vue";
import RedisSlowlogPanel from "./RedisSlowlogPanel.vue";
import * as api from "@/lib/api";
import type { RedisKeyInfo, RedisScanResult, HistoryEntry } from "@/lib/api";
import { uuid } from "@/lib/utils";
@ -46,7 +47,7 @@ interface CreateKeyEntry {
field?: string;
score?: string;
}
type RedisSidePanel = "detail" | "command" | "pubsub";
type RedisSidePanel = "detail" | "command" | "pubsub" | "slowlog";
type RedisCommandHistoryEntry = {
id: number;
prompt: string;
@ -961,6 +962,10 @@ defineExpose({ focusSearch });
<Radio class="size-3.5" />
{{ t("redis.pubsub") }}
</TabsTrigger>
<TabsTrigger value="slowlog" class="h-6 flex-none gap-1.5 rounded-md px-2 text-xs">
<Clock class="size-3.5" />
{{ t("redis.slowlog") }}
</TabsTrigger>
</TabsList>
<Button v-if="activeSidePanel === 'command'" variant="ghost" size="icon" class="h-6 w-6" :title="t('redis.clearHistory')" @click="clearInMemoryHistory">
<History class="size-3.5" />
@ -1010,6 +1015,10 @@ defineExpose({ focusSearch });
<TabsContent value="pubsub" class="m-0 min-h-0 flex-1 flex flex-col">
<RedisPubSubPanel :connection-id="connectionId" :db="db" />
</TabsContent>
<TabsContent value="slowlog" class="m-0 min-h-0 flex-1 flex flex-col">
<RedisSlowlogPanel :connection-id="connectionId" :db="db" />
</TabsContent>
</Tabs>
</div>
</Pane>

View File

@ -0,0 +1,245 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Loader2, Search } from "@lucide/vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import * as api from "@/lib/api";
import type { RedisSlowlogEntry, RedisNodeEndpoint } from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
const props = defineProps<{
connectionId: string;
db: number;
}>();
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
// --- State ---
const count = ref(100);
const nodes = ref<RedisNodeEndpoint[]>([]);
const selectedNodeIndex = ref(-1);
const entries = ref<RedisSlowlogEntry[]>([]);
const sortField = ref<keyof RedisSlowlogEntry>("id");
const sortOrder = ref<"asc" | "desc">("asc");
const loading = ref(false);
const showDetailDialog = ref(false);
const selectedEntry = ref<RedisSlowlogEntry | null>(null);
// --- Connection mode ---
const connectionMode = computed(() => {
return connectionStore.getConfig(props.connectionId)?.redis_connection_mode;
});
const showNodeSelector = computed(() => {
// Cluster mode needs node selection; sentinel connections use Direct path (no selector needed)
return connectionMode.value === "cluster";
});
const nodeOptions = computed(() => {
return nodes.value.map((n) => `${n.host}:${n.port}`);
});
const selectedEndpoint = computed(() => {
if (selectedNodeIndex.value < 0) return null;
return nodes.value[selectedNodeIndex.value] ?? null;
});
const showClientColumns = computed(() => {
return entries.value.some((e) => e.client_addr != null || e.client_name != null);
});
// --- Load cluster nodes on mount ---
onMounted(async () => {
if (connectionMode.value === "cluster") {
try {
nodes.value = await api.redisClusterMasterNodes(props.connectionId);
} catch {
// Silently fail nodes list is best-effort
}
}
});
// --- Sorted entries ---
const sortedEntries = computed(() => {
const field = sortField.value;
const order = sortOrder.value === "asc" ? 1 : -1;
return [...entries.value].sort((a, b) => {
const aVal = a[field];
const bVal = b[field];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
if (typeof aVal === "string" && typeof bVal === "string") {
return aVal.localeCompare(bVal) * order;
}
return (aVal < bVal ? -1 : aVal > bVal ? 1 : 0) * order;
});
});
// --- Methods ---
function sortBy(field: keyof RedisSlowlogEntry) {
if (sortField.value === field) {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
} else {
sortField.value = field;
sortOrder.value = "asc";
}
}
function sortIndicator(field: keyof RedisSlowlogEntry): string {
if (sortField.value !== field) return "";
return sortOrder.value === "asc" ? " ↑" : " ↓";
}
function formatTimestamp(ts: number): string {
if (ts <= 0) return "NIL";
const d = new Date(ts * 1000);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function displayValue(val: string | null): string {
return val ?? "NIL";
}
async function querySlowlog() {
if (showNodeSelector.value && selectedNodeIndex.value < 0) {
toast(t("redis.slowlogNodeRequired"), 3000);
return;
}
loading.value = true;
try {
let result: RedisSlowlogEntry[];
if (showNodeSelector.value && selectedEndpoint.value) {
result = await api.redisSlowlogGet(props.connectionId, count.value, selectedEndpoint.value.host, selectedEndpoint.value.port);
} else {
result = await api.redisSlowlogGet(props.connectionId, count.value);
}
entries.value = result;
// Default sort by id ascending
sortField.value = "id";
sortOrder.value = "asc";
} catch (e) {
toast(t("redis.slowlogFetchFailed", { error: e instanceof Error ? e.message : String(e) }), 5000);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- Top bar: count input, node dropdown, query button -->
<div class="flex items-center gap-2 px-3 py-1.5 border-b bg-muted/30 min-h-0 shrink-0">
<label class="text-xs font-medium whitespace-nowrap shrink-0">{{ t("redis.slowlogCount") }}</label>
<Input v-model.number="count" type="number" min="1" max="10000" class="h-7 w-20 text-xs" :placeholder="t('redis.slowlogCount')" />
<template v-if="showNodeSelector">
<label class="text-xs font-medium whitespace-nowrap shrink-0 ml-1">{{ t("redis.slowlogNode") }}</label>
<Select v-model="selectedNodeIndex">
<SelectTrigger class="h-7 w-auto min-w-[140px] text-xs">
<SelectValue :placeholder="t('redis.slowlogSelectNode')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="(node, idx) in nodeOptions" :key="idx" :value="idx" class="text-xs">
{{ node }}
</SelectItem>
</SelectContent>
</Select>
</template>
<span class="flex-1"></span>
<Button size="sm" class="h-7 text-xs gap-1" :disabled="loading" @click="querySlowlog">
<Loader2 v-if="loading" class="size-3.5 animate-spin" />
<Search v-else class="size-3.5" />
{{ t("redis.slowlogQuery") }}
</Button>
</div>
<!-- Results table -->
<div class="flex-1 flex flex-col min-h-0 relative">
<!-- Empty state -->
<div v-if="entries.length === 0 && !loading" class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
{{ t("redis.slowlogEmpty") }}
</div>
<!-- Table header -->
<div v-if="entries.length > 0" class="flex items-center gap-2 px-3 py-1 border-b shrink-0 bg-background">
<span class="text-xs font-medium">{{ t("redis.slowlog") }}</span>
<span class="text-xs text-muted-foreground">({{ t("redis.slowlogTotal", { count: entries.length }) }})</span>
</div>
<!-- Sortable column headers -->
<div v-if="entries.length > 0" class="flex items-center border-b px-3 shrink-0 bg-muted/20 text-xs font-medium text-muted-foreground select-none" style="height: 28px">
<button class="w-16 shrink-0 text-left hover:text-foreground transition-colors text-xs font-medium" @click="sortBy('id')">{{ t("redis.slowlogColumnId") }}<span v-html="sortIndicator('id')"></span></button>
<button class="w-40 shrink-0 text-left hover:text-foreground transition-colors text-xs font-medium" @click="sortBy('timestamp')">{{ t("redis.slowlogColumnTimestamp") }}<span v-html="sortIndicator('timestamp')"></span></button>
<button class="w-24 shrink-0 text-left hover:text-foreground transition-colors text-xs font-medium" @click="sortBy('duration_micros')">{{ t("redis.slowlogColumnDuration") }}<span v-html="sortIndicator('duration_micros')"></span></button>
<button class="flex-1 min-w-0 text-left hover:text-foreground transition-colors text-xs font-medium" @click="sortBy('command')">{{ t("redis.slowlogColumnCommand") }}<span v-html="sortIndicator('command')"></span></button>
<button v-if="showClientColumns" class="w-32 shrink-0 text-left hover:text-foreground transition-colors text-xs font-medium" @click="sortBy('client_addr')">{{ t("redis.slowlogColumnClientAddr") }}<span v-html="sortIndicator('client_addr')"></span></button>
<button v-if="showClientColumns" class="w-32 shrink-0 text-left hover:text-foreground transition-colors text-xs font-medium" @click="sortBy('client_name')">{{ t("redis.slowlogColumnClientName") }}<span v-html="sortIndicator('client_name')"></span></button>
</div>
<!-- Table with virtual scrolling -->
<div v-if="entries.length > 0" class="flex-1 overflow-hidden">
<RecycleScroller class="h-full" :items="sortedEntries" :item-size="32" :buffer="400" key-field="id" v-slot="{ item }: { item: RedisSlowlogEntry }">
<div
class="flex items-center border-b border-dashed border-border/50 px-3 text-xs hover:bg-muted/30 cursor-pointer"
style="height: 32px"
@click="
selectedEntry = item;
showDetailDialog = true;
"
>
<span class="w-16 shrink-0 text-muted-foreground tabular-nums">{{ item.id }}</span>
<span class="w-40 shrink-0 font-mono tabular-nums">{{ formatTimestamp(item.timestamp) }}</span>
<span class="w-24 shrink-0 font-mono tabular-nums text-muted-foreground">{{ item.duration_micros }}</span>
<span class="flex-1 min-w-0 truncate font-mono" :title="item.command">{{ item.command }}</span>
<span v-if="showClientColumns" class="w-32 shrink-0 text-muted-foreground truncate" :title="displayValue(item.client_addr)">{{ displayValue(item.client_addr) }}</span>
<span v-if="showClientColumns" class="w-32 shrink-0 text-muted-foreground truncate" :title="displayValue(item.client_name)">{{ displayValue(item.client_name) }}</span>
</div>
</RecycleScroller>
</div>
<!-- Loading overlay -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/60 z-10">
<Loader2 class="size-5 animate-spin text-muted-foreground" />
</div>
</div>
<!-- Detail dialog -->
<Dialog v-model:open="showDetailDialog">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle class="text-sm">{{ t("redis.slowlogDetailTitle", { id: selectedEntry?.id ?? "" }) }}</DialogTitle>
</DialogHeader>
<div v-if="selectedEntry" class="grid gap-3 text-xs">
<div class="grid grid-cols-[80px_1fr] gap-x-3 gap-y-2">
<span class="font-medium text-muted-foreground">{{ t("redis.slowlogColumnId") }}</span>
<span class="font-mono">{{ selectedEntry.id }}</span>
<span class="font-medium text-muted-foreground">{{ t("redis.slowlogColumnTimestamp") }}</span>
<span class="font-mono">{{ formatTimestamp(selectedEntry.timestamp) }}</span>
<span class="font-medium text-muted-foreground">{{ t("redis.slowlogColumnDuration") }}</span>
<span class="font-mono">{{ selectedEntry.duration_micros }} μs</span>
<span class="font-medium text-muted-foreground">{{ t("redis.slowlogColumnCommand") }}</span>
<pre class="font-mono whitespace-pre-wrap break-words bg-muted rounded p-2 m-0 max-h-48 overflow-auto">{{ selectedEntry.command }}</pre>
<span class="font-medium text-muted-foreground">{{ t("redis.slowlogColumnClientAddr") }}</span>
<span class="font-mono">{{ displayValue(selectedEntry.client_addr) }}</span>
<span class="font-medium text-muted-foreground">{{ t("redis.slowlogColumnClientName") }}</span>
<span class="font-mono">{{ displayValue(selectedEntry.client_name) }}</span>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</template>

View File

@ -1614,6 +1614,22 @@ export default {
pubsubSend: "Send",
pubsubPublishFailed: "Publish failed: {error}",
pubsubWsConnectFailed: "WebSocket connection failed: {error}",
slowlog: "Slow Log",
slowlogCount: "Count",
slowlogQuery: "Query",
slowlogNode: "Node",
slowlogSelectNode: "Select a node",
slowlogNodeRequired: "Please select a node first",
slowlogEmpty: "Click Query to fetch slow log entries",
slowlogFetchFailed: "Failed to fetch slow log: {error}",
slowlogColumnId: "ID",
slowlogColumnTimestamp: "Timestamp",
slowlogColumnDuration: "Duration (μs)",
slowlogColumnCommand: "Command",
slowlogColumnClientAddr: "Client",
slowlogColumnClientName: "Client Name",
slowlogTotal: "{count} entries",
slowlogDetailTitle: "Slow Log Entry #{id}",
},
mongo: {
documents: "{count} documents",

View File

@ -1613,6 +1613,22 @@ export default {
pubsubSend: "发送",
pubsubPublishFailed: "发布失败: {error}",
pubsubWsConnectFailed: "WebSocket 连接失败: {error}",
slowlog: "慢日志",
slowlogCount: "条数",
slowlogQuery: "查询",
slowlogNode: "节点",
slowlogSelectNode: "请选择节点",
slowlogNodeRequired: "请先选择一个节点",
slowlogEmpty: "点击查询获取慢日志",
slowlogFetchFailed: "获取慢日志失败: {error}",
slowlogColumnId: "ID",
slowlogColumnTimestamp: "时间",
slowlogColumnDuration: "耗时 (μs)",
slowlogColumnCommand: "命令",
slowlogColumnClientAddr: "来源",
slowlogColumnClientName: "客户端名称",
slowlogTotal: "共 {count} 条",
slowlogDetailTitle: "慢日志条目 #{id}",
},
mongo: {
documents: "{count} 个文档",

View File

@ -271,6 +271,8 @@ export const redisFlushDb = forward("redisFlushDb");
export const redisExecuteCommand = forward("redisExecuteCommand");
export const redisLoadMore = forward("redisLoadMore");
export const redisPubSubPublish = forward("redisPubSubPublish");
export const redisSlowlogGet = forward("redisSlowlogGet");
export const redisClusterMasterNodes = forward("redisClusterMasterNodes");
export function redisPubSubConnect(connectionId: string): WebSocket {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
@ -396,6 +398,8 @@ export type {
RedisScanResult,
RedisCommandSafety,
RedisCommandResult,
RedisSlowlogEntry,
RedisNodeEndpoint,
KvValueEncoding,
KvValue,
KvKeyMetadata,

View File

@ -49,6 +49,8 @@ import type {
RedisValue,
RedisScanResult,
RedisCommandResult,
RedisSlowlogEntry,
RedisNodeEndpoint,
KvValue,
KvListPrefixResponse,
KvGetResponse,
@ -1444,6 +1446,14 @@ export async function redisPubSubPublish(connectionId: string, db: number, chann
return post("/api/redis/pubsub/publish", { connectionId, db, channel, message });
}
export async function redisSlowlogGet(connectionId: string, count: number, nodeHost?: string, nodePort?: number): Promise<RedisSlowlogEntry[]> {
return post("/api/redis/slowlog-get", { connectionId, count, nodeHost, nodePort });
}
export async function redisClusterMasterNodes(connectionId: string): Promise<RedisNodeEndpoint[]> {
return post("/api/redis/cluster-master-nodes", { connectionId });
}
// ---------------------------------------------------------------------------
// etcd
// ---------------------------------------------------------------------------

View File

@ -1129,6 +1129,20 @@ export interface RedisCommandResult {
value: any;
}
export interface RedisSlowlogEntry {
id: number;
timestamp: number;
duration_micros: number;
command: string;
client_addr: string | null;
client_name: string | null;
}
export interface RedisNodeEndpoint {
host: string;
port: number;
}
export async function redisListDatabases(connectionId: string): Promise<RedisDatabaseInfo[]> {
return invoke("redis_list_databases", { connectionId });
}
@ -1229,6 +1243,14 @@ export async function redisPubSubPublish(connectionId: string, db: number, chann
return invoke("redis_pubsub_publish", { connectionId, db, channel, message });
}
export async function redisSlowlogGet(connectionId: string, count: number, nodeHost?: string, nodePort?: number): Promise<RedisSlowlogEntry[]> {
return invoke("redis_slowlog_get", { connectionId, count, nodeHost, nodePort });
}
export async function redisClusterMasterNodes(connectionId: string): Promise<RedisNodeEndpoint[]> {
return invoke("redis_cluster_master_nodes", { connectionId });
}
// --- etcd ---
export type KvValueEncoding = "utf8" | "base64";

View File

@ -100,6 +100,16 @@ pub struct RedisClusterAuth {
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisSlowlogEntry {
pub id: u64,
pub timestamp: i64,
pub duration_micros: u64,
pub command: String,
pub client_addr: Option<String>,
pub client_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedisNodeRoute {
pub advertised: RedisNodeEndpoint,
@ -1016,7 +1026,7 @@ pub async fn cluster_key_connection<'a>(
connect_cluster_node(pool, &endpoint).await.map(RedisClusterConnectionGuard::Direct)
}
async fn connect_cluster_node(
pub async fn connect_cluster_node(
pool: &RedisClusterPool,
advertised_endpoint: &RedisNodeEndpoint,
) -> Result<redis::aio::MultiplexedConnection, String> {
@ -1244,6 +1254,119 @@ where
redis::cmd("FLUSHDB").query_async::<()>(con).await.map_err(|e| e.to_string())
}
/// Retrieve slowlog entries via `SLOWLOG GET <count>`.
/// The response is a nested array where each entry has the structure:
/// [id, timestamp_unix_secs, duration_micros, [arg1, arg2, ...], client_addr, client_name, ...]
pub async fn get_slowlog<C>(con: &mut C, count: usize) -> Result<Vec<RedisSlowlogEntry>, String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let raw: RedisRawValue = redis::cmd("SLOWLOG")
.arg("GET")
.arg(count as u64)
.query_async(con)
.await
.map_err(|e| format!("SLOWLOG GET failed: {e}"))?;
let RedisRawValue::Array(entries) = raw else {
return Err("SLOWLOG GET returned non-array response".to_string());
};
let mut result = Vec::with_capacity(entries.len());
for entry in entries {
let RedisRawValue::Array(fields) = entry else {
continue;
};
if fields.len() < 4 {
continue;
}
let id = redis_value_to_u64(&fields[0]).unwrap_or(0);
let timestamp = fields[1].clone();
let duration = fields[2].clone();
let args_raw = fields[3].clone();
let client_addr = if fields.len() > 4 { redis_raw_value_to_optional_string(&fields[4]) } else { None };
let client_name = if fields.len() > 5 { redis_raw_value_to_optional_string(&fields[5]) } else { None };
let command = match args_raw {
RedisRawValue::Array(args) => {
let mut parts = Vec::with_capacity(args.len());
for arg in args {
if let Some(s) = redis_raw_value_to_command_arg(&arg) {
parts.push(s);
}
}
parts.join(" ")
}
_ => String::new(),
};
let timestamp_secs = match timestamp {
RedisRawValue::Int(i) => i,
RedisRawValue::BulkString(ref bytes) => {
std::str::from_utf8(bytes).ok().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0)
}
_ => 0,
};
let duration_micros = match duration {
RedisRawValue::Int(i) => i as u64,
RedisRawValue::BulkString(ref bytes) => {
std::str::from_utf8(bytes).ok().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)
}
_ => 0,
};
result.push(RedisSlowlogEntry {
id,
timestamp: timestamp_secs,
duration_micros,
command,
client_addr,
client_name,
});
}
Ok(result)
}
/// Try to convert a RedisRawValue to an optional string (None for Nil).
fn redis_raw_value_to_optional_string(v: &RedisRawValue) -> Option<String> {
match v {
RedisRawValue::BulkString(bytes) => {
if bytes.is_empty() {
None
} else {
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
}
}
RedisRawValue::SimpleString(s) => Some(s.clone()),
RedisRawValue::Nil => None,
_ => None,
}
}
/// Convert a RedisRawValue to an command argument string.
/// Unlike `redis_raw_value_to_optional_string`, this preserves empty strings
/// and uses `redis_bytes_to_display` to handle non-UTF-8 binary data.
fn redis_raw_value_to_command_arg(v: &RedisRawValue) -> Option<String> {
match v {
RedisRawValue::BulkString(bytes) => Some(redis_bytes_to_display(bytes)),
RedisRawValue::SimpleString(s) => Some(s.clone()),
RedisRawValue::Nil => None,
_ => None,
}
}
/// Try to convert a RedisRawValue to a u64.
fn redis_value_to_u64(v: &RedisRawValue) -> Option<u64> {
match v {
RedisRawValue::Int(i) => Some(*i as u64),
RedisRawValue::BulkString(bytes) => std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok()),
_ => None,
}
}
/// Extract a string reference from a `RedisRawValue` if it is a BulkString or SimpleString.
fn redis_raw_value_as_str(v: &RedisRawValue) -> Option<&str> {
match v {

View File

@ -836,3 +836,49 @@ pub async fn redis_create_pubsub_core(state: &AppState, connection_id: &str) ->
let timeout = std::time::Duration::from_secs(config.effective_connect_timeout_secs());
redis_driver::connect_pubsub(&config, &host, port, timeout).await
}
pub async fn redis_slowlog_get_core(
state: &AppState,
connection_id: &str,
count: usize,
node_host: Option<String>,
node_port: Option<u16>,
) -> Result<Vec<redis_driver::RedisSlowlogEntry>, String> {
ensure_redis_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(redis) => match redis {
RedisConnection::Direct(con) => {
let mut con = con.lock().await;
// SLOWLOG is a server-level command, no select_db needed
redis_driver::get_slowlog(&mut *con, count).await
}
RedisConnection::Cluster(cluster) => {
if let (Some(host), Some(port)) = (node_host.as_ref(), node_port) {
let endpoint = redis_driver::RedisNodeEndpoint { host: host.clone(), port };
let mut con = redis_driver::connect_cluster_node(cluster, &endpoint).await?;
redis_driver::get_slowlog(&mut con, count).await
} else {
// No node specified — return empty (frontend enforces selection)
Ok(Vec::new())
}
}
},
_ => Err("Not a Redis connection".to_string()),
}
}
pub async fn redis_cluster_master_nodes_core(
state: &AppState,
connection_id: &str,
) -> Result<Vec<redis_driver::RedisNodeEndpoint>, String> {
ensure_redis_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Redis(redis) => match redis {
RedisConnection::Cluster(cluster) => redis_driver::cluster_master_nodes(cluster).await,
_ => Ok(Vec::new()),
},
_ => Err("Not a Redis connection".to_string()),
}
}

View File

@ -324,6 +324,9 @@ async fn main() {
.route("/redis/execute-command", post(routes::redis::execute_command))
.route("/redis/pubsub/publish", post(routes::redis::publish_message))
.route("/redis/pubsub/ws", get(routes::redis_pubsub_ws::ws_handler))
// Redis Slowlog
.route("/redis/slowlog-get", post(routes::redis::slowlog_get))
.route("/redis/cluster-master-nodes", post(routes::redis::cluster_master_nodes))
// etcd
.route("/etcd/list-prefix", post(routes::etcd::list_prefix))
.route("/etcd/get", post(routes::etcd::get))

View File

@ -181,6 +181,21 @@ pub struct RedisPubSubPublishRequest {
pub message: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SlowlogGetRequest {
pub connection_id: String,
pub count: usize,
pub node_host: Option<String>,
pub node_port: Option<u16>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClusterNodesRequest {
pub connection_id: String,
}
pub async fn list_databases(
State(state): State<Arc<WebState>>,
Json(req): Json<RedisConnectionRequest>,
@ -507,3 +522,28 @@ pub async fn publish_message(
.map_err(AppError)?;
Ok(Json(serde_json::json!({ "subscribers": count })))
}
pub async fn slowlog_get(
State(state): State<Arc<WebState>>,
Json(req): Json<SlowlogGetRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = dbx_core::redis_ops::redis_slowlog_get_core(
&state.app,
&req.connection_id,
req.count,
req.node_host,
req.node_port,
)
.await
.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn cluster_master_nodes(
State(state): State<Arc<WebState>>,
Json(req): Json<ClusterNodesRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let result =
dbx_core::redis_ops::redis_cluster_master_nodes_core(&state.app, &req.connection_id).await.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}

View File

@ -330,3 +330,22 @@ pub async fn redis_pubsub_publish(
ensure_connection_writable(&state, &connection_id, "PUBLISH").await?;
dbx_core::redis_ops::redis_publish_core(&state, &connection_id, db, &channel, &message).await
}
#[tauri::command]
pub async fn redis_slowlog_get(
state: State<'_, Arc<AppState>>,
connection_id: String,
count: usize,
node_host: Option<String>,
node_port: Option<u16>,
) -> Result<Vec<dbx_core::db::redis_driver::RedisSlowlogEntry>, String> {
dbx_core::redis_ops::redis_slowlog_get_core(&state, &connection_id, count, node_host, node_port).await
}
#[tauri::command]
pub async fn redis_cluster_master_nodes(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<Vec<dbx_core::db::redis_driver::RedisNodeEndpoint>, String> {
dbx_core::redis_ops::redis_cluster_master_nodes_core(&state, &connection_id).await
}

View File

@ -492,6 +492,8 @@ pub fn run() {
commands::redis_cmd::redis_execute_command,
commands::redis_cmd::redis_load_more,
commands::redis_cmd::redis_pubsub_publish,
commands::redis_cmd::redis_slowlog_get,
commands::redis_cmd::redis_cluster_master_nodes,
commands::etcd_cmd::etcd_list_prefix,
commands::etcd_cmd::etcd_get,
commands::etcd_cmd::etcd_put,