feat(redis): persist command history across sessions (#933)
This commit is contained in:
parent
d08385e75f
commit
d0bf6c3b53
|
|
@ -14,6 +14,7 @@ import {
|
|||
KeyRound,
|
||||
TerminalSquare,
|
||||
Asterisk,
|
||||
History,
|
||||
} from "@lucide/vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
|
|
@ -29,7 +30,8 @@ import { Switch } from "@/components/ui/switch";
|
|||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { RedisKeyInfo, RedisScanResult } from "@/lib/api";
|
||||
import type { RedisKeyInfo, RedisScanResult, HistoryEntry } from "@/lib/api";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
|
|
@ -443,19 +445,46 @@ async function runRedisCommand(command: string) {
|
|||
if (result.safety === "confirm") {
|
||||
await loadKeys();
|
||||
}
|
||||
// Persist to history
|
||||
persistRedisHistory(command, true, result.value);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
appendCommandHistory({
|
||||
prompt,
|
||||
command,
|
||||
output: error instanceof Error ? error.message : String(error),
|
||||
output: errorMessage,
|
||||
error: true,
|
||||
});
|
||||
// Persist failed command too
|
||||
persistRedisHistory(command, false, null, errorMessage);
|
||||
} finally {
|
||||
commandRunning.value = false;
|
||||
scrollCommandTerminalToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
function persistRedisHistory(command: string, success: boolean, resultValue?: unknown, errorMessage?: string) {
|
||||
const connName = connectionStore.getConfig(props.connectionId)?.name || "";
|
||||
const entry: HistoryEntry = {
|
||||
id: uuid(),
|
||||
connection_id: props.connectionId,
|
||||
connection_name: connName,
|
||||
database: String(commandDb.value),
|
||||
sql: command,
|
||||
executed_at: new Date().toISOString(),
|
||||
execution_time_ms: 0,
|
||||
success,
|
||||
error: errorMessage,
|
||||
activity_kind: "redis_command",
|
||||
operation: command.split(" ")[0].toUpperCase(),
|
||||
target: "",
|
||||
affected_rows: null,
|
||||
rollback_sql: null,
|
||||
details_json: resultValue != null ? JSON.stringify(resultValue) : null,
|
||||
};
|
||||
void api.saveHistory(entry);
|
||||
}
|
||||
|
||||
async function openCommandPanel() {
|
||||
activeSidePanel.value = "command";
|
||||
await nextTick();
|
||||
|
|
@ -794,9 +823,38 @@ function resumeRedisBrowserBackgroundWork() {
|
|||
registerRedisDbFlushedListener();
|
||||
}
|
||||
|
||||
async function loadPersistedRedisHistory() {
|
||||
try {
|
||||
const entries = await api.loadRedisHistory(200, 0);
|
||||
if (entries.length === 0) return;
|
||||
// Merge persisted entries into in-memory commandHistory (newest first, reversed for display order)
|
||||
const persisted: RedisCommandHistoryEntry[] = entries.reverse().map((entry) => ({
|
||||
id: ++commandHistoryId,
|
||||
prompt: `db${entry.database}>`,
|
||||
command: entry.sql,
|
||||
output: entry.details_json ? formatRedisCommandResult(JSON.parse(entry.details_json)) : entry.error || "",
|
||||
error: !entry.success,
|
||||
}));
|
||||
commandHistory.value = [...persisted, ...commandHistory.value];
|
||||
scrollCommandTerminalToEnd();
|
||||
} catch {
|
||||
// Silently ignore load errors — history is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPersistedRedisHistory() {
|
||||
try {
|
||||
await api.clearRedisHistory();
|
||||
toast(t("redis.historyCleared"));
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resumeRedisBrowserBackgroundWork();
|
||||
void loadKeys();
|
||||
void loadPersistedRedisHistory();
|
||||
});
|
||||
|
||||
onActivated(resumeRedisBrowserBackgroundWork);
|
||||
|
|
@ -1019,7 +1077,7 @@ defineExpose({ focusSearch });
|
|||
<Pane :size="64" :min-size="36">
|
||||
<div class="h-full min-w-0 bg-background flex flex-col overflow-hidden">
|
||||
<Tabs v-model="activeSidePanel" :unmount-on-hide="false" class="h-full min-h-0 gap-0">
|
||||
<div class="h-9 shrink-0 border-b bg-background px-3 flex items-center">
|
||||
<div class="h-9 shrink-0 border-b bg-background px-3 flex items-center justify-between">
|
||||
<TabsList class="h-7 gap-1 p-0.5">
|
||||
<TabsTrigger value="detail" class="h-6 flex-none gap-1.5 rounded-md px-2 text-xs">
|
||||
<KeyRound class="size-3.5" />
|
||||
|
|
@ -1034,6 +1092,16 @@ defineExpose({ focusSearch });
|
|||
{{ t("redis.commandLine") }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
v-if="activeSidePanel === 'command'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="t('redis.clearHistory')"
|
||||
@click="clearPersistedRedisHistory"
|
||||
>
|
||||
<History class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="detail" class="m-0 min-h-0 flex-1 flex flex-col">
|
||||
|
|
|
|||
|
|
@ -1481,6 +1481,8 @@ export default {
|
|||
formatJson: "Format",
|
||||
compressJson: "Compress",
|
||||
jsonFormatError: "Invalid JSON format",
|
||||
clearHistory: "Clear command history",
|
||||
historyCleared: "Redis command history cleared",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documents",
|
||||
|
|
@ -1536,6 +1538,7 @@ export default {
|
|||
schema_change: "Schema change",
|
||||
import: "Import",
|
||||
transfer: "Transfer",
|
||||
redis_command: "Redis Command",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
|
|
@ -1543,6 +1546,7 @@ export default {
|
|||
schema_change: "DDL",
|
||||
import: "Import",
|
||||
transfer: "Move",
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
kind: "Type",
|
||||
|
|
|
|||
|
|
@ -1269,6 +1269,8 @@ export default {
|
|||
jsonView: "Vista JSON",
|
||||
rawContent: "Contenido original",
|
||||
wordWrap: "Ajuste de línea",
|
||||
clearHistory: "Borrar historial de comandos",
|
||||
historyCleared: "Historial de comandos Redis borrado",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documentos",
|
||||
|
|
@ -1321,6 +1323,7 @@ export default {
|
|||
schema_change: "Cambio de esquema",
|
||||
import: "Importación",
|
||||
transfer: "Transferencia",
|
||||
redis_command: "Comando Redis",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
|
|
@ -1328,6 +1331,7 @@ export default {
|
|||
schema_change: "DDL",
|
||||
import: "Import",
|
||||
transfer: "Mover",
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
kind: "Tipo",
|
||||
|
|
|
|||
|
|
@ -1402,6 +1402,8 @@ export default {
|
|||
jsonView: "Visualizzazione JSON",
|
||||
rawContent: "Contenuto grezzo",
|
||||
wordWrap: "A capo automatico",
|
||||
clearHistory: "Cancella cronologia comandi",
|
||||
historyCleared: "Cronologia comandi Redis cancellata",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documenti",
|
||||
|
|
@ -1458,6 +1460,7 @@ export default {
|
|||
schema_change: "Modifica schema",
|
||||
import: "Importazione",
|
||||
transfer: "Trasferimento",
|
||||
redis_command: "Comando Redis",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
|
|
@ -1465,6 +1468,7 @@ export default {
|
|||
schema_change: "DDL",
|
||||
import: "Importa",
|
||||
transfer: "Sposta",
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
kind: "Tipo",
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,8 @@ export default {
|
|||
jsonView: "Visão JSON",
|
||||
rawContent: "Conteúdo bruto",
|
||||
wordWrap: "Quebra de linha",
|
||||
clearHistory: "Limpar histórico de comandos",
|
||||
historyCleared: "Histórico de comandos Redis limpo",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documentos",
|
||||
|
|
@ -1452,6 +1454,7 @@ export default {
|
|||
schema_change: "Alteração de schema",
|
||||
import: "Importação",
|
||||
transfer: "Transferência",
|
||||
redis_command: "Comando Redis",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
|
|
@ -1459,6 +1462,7 @@ export default {
|
|||
schema_change: "DDL",
|
||||
import: "Importar",
|
||||
transfer: "Mover",
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
kind: "Tipo",
|
||||
|
|
|
|||
|
|
@ -1453,6 +1453,8 @@ export default {
|
|||
formatJson: "格式化",
|
||||
compressJson: "压缩",
|
||||
jsonFormatError: "JSON 格式不合法",
|
||||
clearHistory: "清除命令历史",
|
||||
historyCleared: "Redis 命令历史已清除",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} 个文档",
|
||||
|
|
@ -1508,6 +1510,7 @@ export default {
|
|||
schema_change: "结构变更",
|
||||
import: "导入",
|
||||
transfer: "传输",
|
||||
redis_command: "Redis 命令",
|
||||
},
|
||||
kindShort: {
|
||||
query: "查询",
|
||||
|
|
@ -1515,6 +1518,7 @@ export default {
|
|||
schema_change: "结构",
|
||||
import: "导入",
|
||||
transfer: "传输",
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
kind: "类型",
|
||||
|
|
|
|||
|
|
@ -1329,6 +1329,8 @@ export default {
|
|||
jsonView: "JSON 檢視",
|
||||
rawContent: "原始內容",
|
||||
wordWrap: "自動換行",
|
||||
clearHistory: "清除命令歷史",
|
||||
historyCleared: "Redis 命令歷史已清除",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} 個文件",
|
||||
|
|
@ -1384,6 +1386,7 @@ export default {
|
|||
schema_change: "結構變更",
|
||||
import: "匯入",
|
||||
transfer: "傳輸",
|
||||
redis_command: "Redis 命令",
|
||||
},
|
||||
kindShort: {
|
||||
query: "查詢",
|
||||
|
|
@ -1391,6 +1394,7 @@ export default {
|
|||
schema_change: "結構",
|
||||
import: "匯入",
|
||||
transfer: "傳輸",
|
||||
redis_command: "Redis",
|
||||
},
|
||||
detail: {
|
||||
kind: "類型",
|
||||
|
|
|
|||
|
|
@ -269,7 +269,9 @@ export const mongoDeleteDocuments = forward("mongoDeleteDocuments");
|
|||
// History
|
||||
export const saveHistory = forward("saveHistory");
|
||||
export const loadHistory = forward("loadHistory");
|
||||
export const loadRedisHistory = forward("loadRedisHistory");
|
||||
export const clearHistory = forward("clearHistory");
|
||||
export const clearRedisHistory = forward("clearRedisHistory");
|
||||
export const deleteHistoryEntry = forward("deleteHistoryEntry");
|
||||
|
||||
// Updates
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type HistoryActivityKind = "query" | "data_change" | "schema_change" | "import" | "transfer";
|
||||
export type HistoryActivityKind = "query" | "data_change" | "schema_change" | "import" | "transfer" | "redis_command";
|
||||
|
||||
export type HistoryActivitySource = {
|
||||
activity_kind?: HistoryActivityKind;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export type HistoryAiAnalysisEntry = {
|
|||
execution_time_ms: number;
|
||||
success: boolean;
|
||||
error?: string | null;
|
||||
activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer";
|
||||
activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer" | "redis_command";
|
||||
operation?: string;
|
||||
target?: string;
|
||||
affected_rows?: number | null;
|
||||
|
|
|
|||
|
|
@ -1602,14 +1602,23 @@ export async function saveHistory(entry: HistoryEntry): Promise<void> {
|
|||
return post("/api/history/save", { entry });
|
||||
}
|
||||
|
||||
export async function loadHistory(limit: number, offset: number): Promise<HistoryEntry[]> {
|
||||
return get(`/api/history?${qs({ limit, offset })}`);
|
||||
export async function loadHistory(limit: number, offset: number, activityKind?: string): Promise<HistoryEntry[]> {
|
||||
return get(`/api/history?${qs({ limit, offset, activity_kind: activityKind })}`);
|
||||
}
|
||||
|
||||
export async function loadRedisHistory(limit = 100, offset = 0): Promise<HistoryEntry[]> {
|
||||
return loadHistory(limit, offset, "redis_command");
|
||||
}
|
||||
|
||||
export async function clearHistory(): Promise<void> {
|
||||
return del("/api/history");
|
||||
}
|
||||
|
||||
export async function clearRedisHistory(): Promise<void> {
|
||||
const entries = await loadRedisHistory(1000, 0);
|
||||
await Promise.all(entries.map((e) => deleteHistoryEntry(e.id)));
|
||||
}
|
||||
|
||||
export async function deleteHistoryEntry(id: string): Promise<void> {
|
||||
return del(`/api/history/${id}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1490,7 +1490,7 @@ export interface HistoryEntry {
|
|||
execution_time_ms: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer";
|
||||
activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer" | "redis_command";
|
||||
operation?: string;
|
||||
target?: string;
|
||||
affected_rows?: number | null;
|
||||
|
|
@ -1502,14 +1502,23 @@ export async function saveHistory(entry: HistoryEntry): Promise<void> {
|
|||
return invoke("save_history", { entry });
|
||||
}
|
||||
|
||||
export async function loadHistory(limit: number, offset: number): Promise<HistoryEntry[]> {
|
||||
return invoke("load_history", { limit, offset });
|
||||
export async function loadHistory(limit: number, offset: number, activityKind?: string): Promise<HistoryEntry[]> {
|
||||
return invoke("load_history", { limit, offset, activityKind: activityKind ?? null });
|
||||
}
|
||||
|
||||
export async function loadRedisHistory(limit = 100, offset = 0): Promise<HistoryEntry[]> {
|
||||
return loadHistory(limit, offset, "redis_command");
|
||||
}
|
||||
|
||||
export async function clearHistory(): Promise<void> {
|
||||
return invoke("clear_history");
|
||||
}
|
||||
|
||||
export async function clearRedisHistory(): Promise<void> {
|
||||
const entries = await loadRedisHistory(1000, 0);
|
||||
await Promise.all(entries.map((e) => deleteHistoryEntry(e.id)));
|
||||
}
|
||||
|
||||
export async function deleteHistoryEntry(id: string): Promise<void> {
|
||||
return invoke("delete_history_entry", { id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -338,44 +338,61 @@ impl Storage {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn load_history_entries(&self, limit: usize, offset: usize) -> Result<Vec<HistoryEntry>, String> {
|
||||
pub async fn load_history_entries(
|
||||
&self,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
activity_kind: Option<String>,
|
||||
) -> Result<Vec<HistoryEntry>, String> {
|
||||
self.with_conn(move |conn| {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \
|
||||
error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \
|
||||
FROM history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit as i64, offset as i64], |row| {
|
||||
Ok(HistoryEntry {
|
||||
id: row.get(0)?,
|
||||
connection_name: row.get(1)?,
|
||||
database: row.get(2)?,
|
||||
sql: row.get(3)?,
|
||||
executed_at: row.get(4)?,
|
||||
execution_time_ms: row.get::<_, i64>(5)? as u128,
|
||||
success: row.get(6)?,
|
||||
error: row.get(7)?,
|
||||
activity_kind: {
|
||||
let value: String = row.get(8)?;
|
||||
if value.is_empty() {
|
||||
"query".to_string()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
},
|
||||
connection_id: row.get(9)?,
|
||||
operation: row.get(10)?,
|
||||
target: row.get(11)?,
|
||||
affected_rows: row.get(12)?,
|
||||
rollback_sql: row.get(13)?,
|
||||
details_json: row.get(14)?,
|
||||
})
|
||||
let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<HistoryEntry> {
|
||||
Ok(HistoryEntry {
|
||||
id: row.get(0)?,
|
||||
connection_name: row.get(1)?,
|
||||
database: row.get(2)?,
|
||||
sql: row.get(3)?,
|
||||
executed_at: row.get(4)?,
|
||||
execution_time_ms: row.get::<_, i64>(5)? as u128,
|
||||
success: row.get(6)?,
|
||||
error: row.get(7)?,
|
||||
activity_kind: {
|
||||
let value: String = row.get(8)?;
|
||||
if value.is_empty() { "query".to_string() } else { value }
|
||||
},
|
||||
connection_id: row.get(9)?,
|
||||
operation: row.get(10)?,
|
||||
target: row.get(11)?,
|
||||
affected_rows: row.get(12)?,
|
||||
rollback_sql: row.get(13)?,
|
||||
details_json: row.get(14)?,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
};
|
||||
|
||||
if let Some(kind) = activity_kind {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \
|
||||
error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \
|
||||
FROM history WHERE activity_kind = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(params![kind, limit as i64, offset as i64], map_row)
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
} else {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \
|
||||
error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \
|
||||
FROM history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit as i64, offset as i64], map_row)
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::state::WebState;
|
|||
pub struct HistoryQuery {
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
pub activity_kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -34,7 +35,7 @@ pub async fn load_history(
|
|||
) -> Result<Json<Vec<HistoryEntry>>, AppError> {
|
||||
let limit = q.limit.unwrap_or(100);
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
let entries = state.app.storage.load_history_entries(limit, offset).await.map_err(AppError)?;
|
||||
let entries = state.app.storage.load_history_entries(limit, offset, q.activity_kind).await.map_err(AppError)?;
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ pub async fn load_history(
|
|||
state: State<'_, Arc<AppState>>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
activity_kind: Option<String>,
|
||||
) -> Result<Vec<HistoryEntry>, String> {
|
||||
state.storage.load_history_entries(limit, offset).await
|
||||
state.storage.load_history_entries(limit, offset, activity_kind).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
Loading…
Reference in New Issue