Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/desktop/src/components/redis/RedisKeyBrowser.vue
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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" />
Expand Down Expand Up @@ -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>
Expand Down
238 changes: 238 additions & 0 deletions apps/desktop/src/components/redis/RedisSlowlogPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<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 selectedNode = ref<string>("");
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}`);
});

// --- 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 && !selectedNode.value) {
toast(t("redis.slowlogNodeRequired"), 3000);
return;
}

loading.value = true;
try {
let result: RedisSlowlogEntry[];
if (showNodeSelector.value && selectedNode.value) {
const [host, portStr] = selectedNode.value.split(":");
const port = parseInt(portStr, 10);
result = await api.redisSlowlogGet(props.connectionId, count.value, host, isNaN(port) ? undefined : 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="selectedNode">
<SelectTrigger class="h-7 w-auto min-w-[140px] text-xs">
<SelectValue :placeholder="t('redis.slowlogSelectNode')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="node in nodeOptions" :key="node" :value="node" 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 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 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 class="w-32 shrink-0 text-muted-foreground truncate" :title="displayValue(item.client_addr)">{{ displayValue(item.client_addr) }}</span>
<span 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>
16 changes: 16 additions & 0 deletions apps/desktop/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} 个文档",
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:";
Expand Down Expand Up @@ -396,6 +398,8 @@ export type {
RedisScanResult,
RedisCommandSafety,
RedisCommandResult,
RedisSlowlogEntry,
RedisNodeEndpoint,
KvValueEncoding,
KvValue,
KvKeyMetadata,
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import type {
RedisValue,
RedisScanResult,
RedisCommandResult,
RedisSlowlogEntry,
RedisNodeEndpoint,
KvValue,
KvListPrefixResponse,
KvGetResponse,
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading