feat(history): track database activity

This commit is contained in:
t8y2
2026-05-10 14:18:20 +08:00
parent dadcd1231f
commit f5b36a0f60
10 changed files with 556 additions and 57 deletions
+200 -30
View File
@@ -1,39 +1,60 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Search, Trash2, Clock, X } from "lucide-vue-next";
import { Clock, Copy, Database, RotateCcw, Search, Trash2, X } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import { useHistoryStore } from "@/stores/historyStore";
import { useToast } from "@/composables/useToast";
import { shouldClearHistory, shouldDeleteHistoryEntry } from "@/lib/historyActions";
import type { HistoryEntry } from "@/lib/api";
import * as api from "@/lib/api";
const { t } = useI18n();
const { toast } = useToast();
const store = useHistoryStore();
const emit = defineEmits<{
restore: [sql: string];
restore: [sql: string, entry: HistoryEntry];
close: [];
}>();
type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed";
const searchText = ref("");
const activeFilter = ref<HistoryFilter>("all");
const selectedEntry = ref<HistoryEntry | null>(null);
const isRollingBack = ref(false);
const filters: HistoryFilter[] = ["all", "query", "data_change", "schema_change", "failed"];
const filtered = computed(() => {
if (!searchText.value) return store.entries;
const q = searchText.value.toLowerCase();
return store.entries.filter(
(e) =>
e.sql.toLowerCase().includes(q) ||
e.connection_name.toLowerCase().includes(q) ||
e.database.toLowerCase().includes(q),
);
return store.entries.filter((entry) => {
if (activeFilter.value === "failed" && entry.success) return false;
if (activeFilter.value !== "all" && activeFilter.value !== "failed" && activityKind(entry) !== activeFilter.value) {
return false;
}
if (!q) return true;
return [entry.sql, entry.connection_name, entry.database, entry.operation, entry.target]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(q));
});
});
function restore(sql: string) {
emit("restore", sql);
function activityKind(entry: HistoryEntry) {
return entry.activity_kind || "query";
}
function copySql(sql: string) {
navigator.clipboard.writeText(sql);
function restore(entry: HistoryEntry) {
emit("restore", entry.sql, entry);
selectedEntry.value = null;
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
toast(t("grid.copied"));
}
function confirmDeleteEntry(id: string) {
@@ -54,11 +75,83 @@ function formatTime(iso: string): string {
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatFullTime(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
function truncateSql(sql: string): string {
const line = sql.replace(/\s+/g, " ").trim();
return line.length > 120 ? line.slice(0, 120) + "..." : line;
}
function entryTitle(entry: HistoryEntry) {
return entry.target || entry.operation || truncateSql(entry.sql);
}
function entrySubtitle(entry: HistoryEntry) {
if (activityKind(entry) === "query") return truncateSql(entry.sql);
return truncateSql(entry.sql || entry.target || entry.operation || "");
}
function filterLabel(filter: HistoryFilter) {
return t(`history.filters.${filter}`);
}
function kindLabel(entry: HistoryEntry) {
return t(`history.kinds.${activityKind(entry)}`);
}
function kindShortLabel(entry: HistoryEntry) {
return t(`history.kindShort.${activityKind(entry)}`);
}
function detailsRows(entry: HistoryEntry) {
const rows = [
[t("history.detail.kind"), kindLabel(entry)],
[t("history.detail.operation"), entry.operation || "-"],
[t("history.detail.connection"), entry.connection_name || "-"],
[t("history.detail.database"), entry.database || "-"],
[t("history.detail.target"), entry.target || "-"],
[t("history.detail.time"), formatFullTime(entry.executed_at)],
[t("history.detail.duration"), `${entry.execution_time_ms}ms`],
[t("history.detail.affectedRows"), entry.affected_rows ?? "-"],
[t("history.detail.status"), entry.success ? t("history.success") : t("history.failed")],
];
if (entry.error) rows.push([t("history.detail.error"), entry.error]);
return rows;
}
async function rollback(entry: HistoryEntry) {
if (!entry.connection_id || !entry.database || !entry.rollback_sql || isRollingBack.value) return;
if (!window.confirm(t("history.rollbackConfirm"))) return;
isRollingBack.value = true;
const start = Date.now();
try {
const result = await api.executeScript(entry.connection_id, entry.database, entry.rollback_sql);
await store.add({
connection_id: entry.connection_id,
connection_name: entry.connection_name,
database: entry.database,
sql: entry.rollback_sql,
execution_time_ms: Date.now() - start,
success: true,
activity_kind: "data_change",
operation: "ROLLBACK",
target: entry.target,
affected_rows: result.affected_rows,
details_json: JSON.stringify({ rollback_of: entry.id }),
});
toast(t("history.rollbackSuccess"));
selectedEntry.value = null;
} catch (e: any) {
toast(t("history.rollbackFailed", { message: e?.message || String(e) }), 5000);
} finally {
isRollingBack.value = false;
}
}
onMounted(() => store.load());
</script>
@@ -76,16 +169,30 @@ onMounted(() => store.load());
</Button>
</div>
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('history.search')"
/>
<div class="border-b shrink-0">
<div class="flex items-center gap-1 px-2 py-1">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('history.search')"
/>
</div>
<div class="flex gap-1 overflow-x-auto px-2 pb-2">
<button
v-for="filter in filters"
:key="filter"
type="button"
class="h-6 shrink-0 rounded border px-2 text-xs"
:class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'"
@click="activeFilter = filter"
>
{{ filterLabel(filter) }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto">
@@ -93,24 +200,36 @@ onMounted(() => store.load());
<ContextMenuTrigger as-child>
<div
class="px-3 py-2 border-b border-border/50 cursor-pointer hover:bg-accent/50 text-xs"
@click="restore(entry.sql)"
@click="selectedEntry = entry"
>
<div class="flex items-center gap-1 mb-0.5">
<span class="font-medium truncate">{{ entry.connection_name }}</span>
<span v-if="entry.database" class="text-muted-foreground">/ {{ entry.database }}</span>
<span
class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground"
>
{{ kindShortLabel(entry) }}
</span>
<span class="font-medium truncate">{{ entryTitle(entry) }}</span>
<span class="ml-auto text-muted-foreground shrink-0">{{ formatTime(entry.executed_at) }}</span>
</div>
<div class="font-mono text-muted-foreground truncate">{{ truncateSql(entry.sql) }}</div>
<div class="font-mono text-muted-foreground truncate">{{ entrySubtitle(entry) }}</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="inline-flex items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3" />
{{ entry.connection_name }}<template v-if="entry.database"> / {{ entry.database }}</template>
</span>
<span :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
</span>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-40">
<ContextMenuItem @click="restore(entry.sql)">{{ t("history.restore") }}</ContextMenuItem>
<ContextMenuItem @click="copySql(entry.sql)">{{ t("history.copy") }}</ContextMenuItem>
<ContextMenuContent class="w-44">
<ContextMenuItem @click="selectedEntry = entry">{{ t("history.viewDetails") }}</ContextMenuItem>
<ContextMenuItem @click="restore(entry)">{{ t("history.restore") }}</ContextMenuItem>
<ContextMenuItem @click="copyText(entry.sql)">{{ t("history.copy") }}</ContextMenuItem>
<ContextMenuItem v-if="entry.rollback_sql" @click="rollback(entry)">{{
t("history.rollback")
}}</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{
t("history.delete")
}}</ContextMenuItem>
@@ -121,5 +240,56 @@ onMounted(() => store.load());
{{ t("history.empty") }}
</div>
</div>
<Dialog :open="!!selectedEntry" @update:open="(value) => !value && (selectedEntry = null)">
<DialogContent class="sm:max-w-2xl duration-75" @interact-outside="selectedEntry = null">
<DialogHeader>
<DialogTitle>{{ selectedEntry ? entryTitle(selectedEntry) : t("history.viewDetails") }}</DialogTitle>
</DialogHeader>
<div v-if="selectedEntry" class="space-y-4">
<div class="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2 text-sm">
<template v-for="[label, value] in detailsRows(selectedEntry)" :key="label">
<div class="text-muted-foreground">{{ label }}</div>
<div class="min-w-0 break-words">{{ value }}</div>
</template>
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<div class="text-sm font-medium">SQL</div>
<Button variant="ghost" size="sm" class="h-7" @click="copyText(selectedEntry.sql)">
<Copy class="h-3.5 w-3.5" />
{{ t("history.copy") }}
</Button>
</div>
<pre
class="max-h-48 overflow-auto rounded border bg-muted/30 p-3 text-xs"
><code>{{ selectedEntry.sql }}</code></pre>
</div>
<div v-if="selectedEntry.rollback_sql">
<div class="mb-1 flex items-center justify-between">
<div class="text-sm font-medium">{{ t("history.rollbackSql") }}</div>
<Button variant="ghost" size="sm" class="h-7" @click="copyText(selectedEntry.rollback_sql || '')">
<Copy class="h-3.5 w-3.5" />
{{ t("history.copy") }}
</Button>
</div>
<pre
class="max-h-40 overflow-auto rounded border bg-muted/30 p-3 text-xs"
><code>{{ selectedEntry.rollback_sql }}</code></pre>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
<Button
v-if="selectedEntry?.rollback_sql"
:disabled="isRollingBack || !selectedEntry.connection_id"
@click="rollback(selectedEntry)"
>
<RotateCcw class="h-4 w-4" />
{{ isRollingBack ? t("common.loading") : t("history.rollback") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
+70 -8
View File
@@ -73,7 +73,7 @@ import {
type CellSelectionRange,
} from "@/lib/gridSelection";
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql";
import { buildDataGridRollbackStatements, buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql";
import { formatMarkdownTable } from "@/lib/markdownTable";
import { buildXlsxWorkbook } from "@/lib/xlsxExport";
import {
@@ -84,9 +84,13 @@ import {
} from "@/lib/gridRowStatus";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const historyStore = useHistoryStore();
const props = defineProps<{
result: QueryResult;
@@ -1539,28 +1543,81 @@ function deleteSelectedRow() {
requestDeleteRow(contextCell.value.rowId);
}
function generateSaveStatements(): string[] {
if (!props.tableMeta) return [];
return buildDataGridSaveStatements({
function saveStatementOptions() {
if (!props.tableMeta) return null;
return {
databaseType: props.databaseType,
tableMeta: props.tableMeta,
columns: props.result.columns,
rows: props.result.rows,
dirtyRows: [...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]),
dirtyRows: [...dirtyRows.value.entries()].map(
([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>],
),
deletedRows: [...deletedRows.value],
newRows: newRows.value,
};
}
function tableHistoryTarget() {
if (!props.tableMeta) return "";
return [props.tableMeta.schema, props.tableMeta.tableName].filter(Boolean).join(".");
}
function dataChangeOperation() {
const operations = [
newRows.value.length > 0 ? "INSERT" : "",
dirtyRows.value.size > 0 ? "UPDATE" : "",
deletedRows.value.size > 0 ? "DELETE" : "",
].filter(Boolean);
return operations.length === 1 ? operations[0] : "DATA CHANGE";
}
async function recordDataGridHistory(
statements: string[],
rollbackStatements: string[],
elapsed: number,
result?: { affected_rows?: number },
) {
if (!props.connectionId || !props.database || !props.tableMeta) return;
const connName = connectionStore.getConfig(props.connectionId)?.name || "";
const details = {
schema: props.tableMeta.schema,
table: props.tableMeta.tableName,
inserted_rows: newRows.value.length,
updated_rows: dirtyRows.value.size,
deleted_rows: deletedRows.value.size,
statements,
rollback_statements: rollbackStatements,
};
await historyStore.add({
connection_id: props.connectionId,
connection_name: connName,
database: props.database,
sql: statements.join("\n"),
execution_time_ms: elapsed,
success: true,
activity_kind: "data_change",
operation: dataChangeOperation(),
target: tableHistoryTarget(),
affected_rows: result?.affected_rows ?? statements.length,
rollback_sql: rollbackStatements.length ? rollbackStatements.join("\n") : undefined,
details_json: JSON.stringify(details),
});
}
async function saveChanges() {
const stmts = generateSaveStatements();
const options = saveStatementOptions();
const stmts = options ? buildDataGridSaveStatements(options) : [];
if (stmts.length === 0) return;
const rollbackStmts = options ? buildDataGridRollbackStatements(options) : [];
saveError.value = "";
isSaving.value = true;
const start = Date.now();
let result: { affected_rows?: number } | undefined;
if (useTransaction.value && props.connectionId && props.database) {
try {
await api.executeInTransaction(props.connectionId, props.database, stmts, props.tableMeta?.schema);
result = await api.executeInTransaction(props.connectionId, props.database, stmts, props.tableMeta?.schema);
} catch (e: any) {
saveError.value = String(e.message || e);
isSaving.value = false;
@@ -1568,7 +1625,7 @@ async function saveChanges() {
}
} else if (props.connectionId && props.database) {
try {
await api.executeBatch(props.connectionId, props.database, stmts);
result = await api.executeBatch(props.connectionId, props.database, stmts);
} catch (e: any) {
saveError.value = String(e.message || e);
isSaving.value = false;
@@ -1585,6 +1642,11 @@ async function saveChanges() {
return;
}
}
try {
await recordDataGridHistory(stmts, rollbackStmts, Date.now() - start, result);
} catch (e) {
console.warn("[DBX] failed to record data grid history", e);
}
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();