feat(history): add execution time range filter

This commit is contained in:
funnytime authored and GitHub committed 2026-06-20 22:14:15 +08:00
1 parent 0720a86d25
commit d1fb9b8445
10 files changed
+285 -2

No files matched your search

@@ -2,15 +2,17 @@
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
import { CalendarClock, Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useHistoryStore } from "@/stores/historyStore";
import { useToast } from "@/composables/useToast";
import { resolveHistoryActivityKind } from "@/lib/historyActivityKind";
import { canRollbackHistoryEntry } from "@/lib/historyAiAnalysis";
import { hasHistoryDateRange, historyDateRangeIsValid, historyEntryMatchesDateRange, type HistoryDateRange } from "@/lib/historyTimeRange";
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/historyVirtualList";
import type { HistoryEntry } from "@/lib/api";
import { copyToClipboard } from "@/lib/clipboard";
@@ -31,6 +33,11 @@ type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed
const searchText = ref("");
const activeFilter = ref<HistoryFilter>("all");
const dateRange = ref<HistoryDateRange>({ startDate: "", endDate: "" });
const dateRangeDraft = ref<HistoryDateRange>({ startDate: "", endDate: "" });
const dateRangeOpen = ref(false);
const startDateInputRef = ref<HTMLInputElement | null>(null);
const endDateInputRef = ref<HTMLInputElement | null>(null);
const selectedEntry = ref<HistoryEntry | null>(null);
const isRollingBack = ref(false);
const showDeleteConfirm = ref(false);
@@ -38,6 +45,14 @@ const showClearConfirm = ref(false);
const deleteTargetId = ref<string | null>(null);
const filters: HistoryFilter[] = ["all", "query", "data_change", "schema_change", "failed"];
const hasDateFilter = computed(() => hasHistoryDateRange(dateRange.value));
const dateRangeDraftValid = computed(() => historyDateRangeIsValid(dateRangeDraft.value));
const dateRangeSummary = computed(() => {
if (!hasDateFilter.value) return "";
const start = dateRange.value.startDate || t("history.dateRange.unboundedStart");
const end = dateRange.value.endDate || t("history.dateRange.unboundedEnd");
return `${start} -> ${end}`;
});
const filtered = computed(() => {
const q = searchText.value.toLowerCase();
@@ -46,10 +61,12 @@ const filtered = computed(() => {
if (activeFilter.value !== "all" && activeFilter.value !== "failed" && activityKind(entry) !== activeFilter.value) {
return false;
}
if (!historyEntryMatchesDateRange(entry.executed_at, dateRange.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));
});
});
const emptyMessage = computed(() => (store.entries.length > 0 ? t("history.emptyFilteredRecent") : t("history.empty")));
function activityKind(entry: HistoryEntry) {
return resolveHistoryActivityKind(entry);
@@ -122,6 +139,46 @@ function filterLabel(filter: HistoryFilter) {
return t(`history.filters.${filter}`);
}
function openDateRangeFilter() {
dateRangeDraft.value = { ...dateRange.value };
}
function setDateRangeOpen(value: boolean) {
if (value) openDateRangeFilter();
dateRangeOpen.value = value;
}
function applyDateRangeFilter() {
if (!dateRangeDraftValid.value) return;
dateRange.value = { ...dateRangeDraft.value };
dateRangeOpen.value = false;
}
function clearDateRangeFilter() {
dateRange.value = { startDate: "", endDate: "" };
dateRangeDraft.value = { startDate: "", endDate: "" };
}
function cancelDateRangeFilter() {
dateRangeDraft.value = { ...dateRange.value };
dateRangeOpen.value = false;
}
function dateFieldLabel(value: string) {
return value ? value.replaceAll("-", "/") : "yyyy/mm/dd";
}
function openDatePicker(input: HTMLInputElement | null) {
if (!input) return;
input.focus();
const pickerInput = input as HTMLInputElement & { showPicker?: () => void };
if (pickerInput.showPicker) {
pickerInput.showPicker();
} else {
input.click();
}
}
function kindLabel(entry: HistoryEntry) {
return t(`history.kinds.${activityKind(entry)}`);
}
@@ -220,6 +277,72 @@ onMounted(() => store.load());
<div class="relative flex items-center px-2 py-1">
<Search class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
<input v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground" :placeholder="t('history.search')" />
<Popover :open="dateRangeOpen" @update:open="setDateRangeOpen">
<PopoverTrigger as-child>
<button
type="button"
class="ml-1 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors"
:class="hasDateFilter ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
:title="t('history.dateRange.title')"
>
<CalendarClock class="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-auto max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
<div class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("history.dateRange.title") }}</div>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="grid min-w-0 gap-1 text-[11px] text-muted-foreground">
<span>{{ t("history.dateRange.start") }}</span>
<input ref="startDateInputRef" v-model="dateRangeDraft.startDate" type="date" class="sr-only" tabindex="-1" aria-hidden="true" />
<button type="button" class="flex h-8 w-28 min-w-0 items-center gap-1.5 rounded-md border border-input bg-background px-2 text-left text-xs text-foreground outline-none hover:bg-muted/50 focus:border-ring focus:ring-2 focus:ring-ring/30" @click="openDatePicker(startDateInputRef)">
<span class="min-w-0 flex-1 truncate tabular-nums" :class="{ 'text-muted-foreground': !dateRangeDraft.startDate }">
{{ dateFieldLabel(dateRangeDraft.startDate) }}
</span>
<CalendarClock class="h-3 w-3 shrink-0 text-muted-foreground" />
</button>
</label>
<span class="pb-2 text-xs text-muted-foreground">-></span>
<label class="grid min-w-0 gap-1 text-[11px] text-muted-foreground">
<span>{{ t("history.dateRange.end") }}</span>
<input ref="endDateInputRef" v-model="dateRangeDraft.endDate" type="date" class="sr-only" tabindex="-1" aria-hidden="true" />
<button type="button" class="flex h-8 w-28 min-w-0 items-center gap-1.5 rounded-md border border-input bg-background px-2 text-left text-xs text-foreground outline-none hover:bg-muted/50 focus:border-ring focus:ring-2 focus:ring-ring/30" @click="openDatePicker(endDateInputRef)">
<span class="min-w-0 flex-1 truncate tabular-nums" :class="{ 'text-muted-foreground': !dateRangeDraft.endDate }">
{{ dateFieldLabel(dateRangeDraft.endDate) }}
</span>
<CalendarClock class="h-3 w-3 shrink-0 text-muted-foreground" />
</button>
</label>
</div>
<div v-if="!dateRangeDraftValid" class="rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{{ t("history.dateRange.invalid") }}
</div>
<div class="flex items-center justify-between gap-2 pt-1">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearDateRangeFilter">
{{ t("history.dateRange.clear") }}
</Button>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" @click="cancelDateRangeFilter">
{{ t("dangerDialog.cancel") }}
</Button>
<Button size="sm" class="h-8 px-3 text-xs" :disabled="!dateRangeDraftValid" @click="applyDateRangeFilter">
{{ t("history.dateRange.apply") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div v-if="hasDateFilter" class="flex items-center gap-1 px-2 pb-2">
<button type="button" class="flex min-w-0 items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary hover:bg-primary/15" :title="t('history.dateRange.title')" @click="setDateRangeOpen(true)">
<CalendarClock class="h-3 w-3 shrink-0" />
<span class="shrink-0">{{ t("history.dateRange.label") }}</span>
<span class="min-w-0 truncate tabular-nums">{{ dateRangeSummary }}</span>
</button>
<button type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :title="t('history.dateRange.clear')" @click="clearDateRangeFilter">
<X class="h-3 w-3" />
</button>
</div>
</div>
@@ -253,7 +376,7 @@ onMounted(() => store.load());
</RecycleScroller>
<div v-if="filtered.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t("history.empty") }}
{{ emptyMessage }}
</div>
</div>