perf: full-stack performance and security hardening

Frontend performance:
- Debounce DataGrid search (150ms) and replace O(n) row wrapping with index array
- Replace QueryStore deep watcher with targeted field-level persistence snapshot
- Extract shared nodeConfig computed in TreeItem (eliminates 15x redundant getConfig calls)
- Clear tab result data on close to free memory
- Add LRU eviction for completion caches (50 entry cap)
- Replace savedSql version string join with numeric counter

Memory leak fixes:
- Store Tauri event unlisten handles and clean up on unmount
- Clean up DataGrid search timer on unmount

Backend security & resource management:
- Add 50MB request body size limit
- Reject file uploads >100MB and SQL files >200MB
- Clean up SSE channels after transfer/import completes
- Recover from poisoned mutex instead of panicking
- Delete temporary upload files after preview
- Validate SQL file paths to prevent directory traversal
This commit is contained in:
t8y2
2026-05-11 00:18:09 +08:00
parent 741783445d
commit f456ddd979
13 changed files with 230 additions and 87 deletions
+38 -11
View File
@@ -219,6 +219,9 @@ const sortCol = ref<string | null>(null);
const sortColIndex = ref<number | null>(null);
const sortDir = ref<"asc" | "desc">("asc");
const searchText = ref("");
const deferredClientSearchText = ref("");
let _searchTimer: ReturnType<typeof setTimeout> | undefined;
const searchSuggestions = ref<string[]>([]);
const suggestionIndex = ref(-1);
const searchInputRef = ref<HTMLInputElement>();
@@ -279,9 +282,18 @@ function rowMatchesLocalColumnFilters(data: CellValue[]): boolean {
}
const localFilteredRows = computed(() => {
const rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex }));
if (!hasLocalColumnFilters.value) return rows;
return rows.filter(({ row, sourceIndex }) => rowMatchesLocalColumnFilters(rowDataWithChanges(row, sourceIndex)));
const rows = props.result.rows;
const indices: number[] = [];
if (!hasLocalColumnFilters.value) {
for (let i = 0; i < rows.length; i++) indices.push(i);
return indices;
}
for (let i = 0; i < rows.length; i++) {
if (rowMatchesLocalColumnFilters(rowDataWithChanges(rows[i], i))) {
indices.push(i);
}
}
return indices;
});
function buildLocalFilterOptions(columnIndex: number) {
@@ -838,6 +850,17 @@ const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
const isResultsContext = computed(() => props.context === "results");
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
const clientSearchText = computed(() => (searchText.value.trim() ? searchText.value : ""));
watch(clientSearchText, (value) => {
clearTimeout(_searchTimer);
const q = value.trim().toLowerCase();
if (!q) {
deferredClientSearchText.value = "";
return;
}
_searchTimer = setTimeout(() => {
deferredClientSearchText.value = q;
}, 150);
});
function currentWhereInput(): string | undefined {
return whereFilterInput.value.trim() || undefined;
@@ -936,15 +959,16 @@ function onToolbarRollback() {
}
const sortedRows = computed(() => {
let rows = localFilteredRows.value;
if (clientSearchText.value) {
const q = clientSearchText.value.toLowerCase();
rows = rows.filter(({ row, sourceIndex }) => {
const data = rowDataWithChanges(row, sourceIndex);
let indices = localFilteredRows.value;
const q = deferredClientSearchText.value;
if (q) {
const rows = props.result.rows;
indices = indices.filter((sourceIndex) => {
const data = rowDataWithChanges(rows[sourceIndex], sourceIndex);
return data.some((cell) => cell !== null && String(cell).toLowerCase().includes(q));
});
}
return rows;
return indices;
});
function rowDataWithChanges(row: CellValue[], sourceIndex: number): CellValue[] {
@@ -965,7 +989,9 @@ interface RowItem {
const displayItems = computed<RowItem[]>(() => {
const cols = props.result.columns;
const items: RowItem[] = sortedRows.value.map(({ row, sourceIndex }) => {
const rows = props.result.rows;
const items: RowItem[] = sortedRows.value.map((sourceIndex) => {
const row = rows[sourceIndex];
const dirty = dirtyRows.value.get(sourceIndex);
const data = rowDataWithChanges(row, sourceIndex);
const isDirtyCol = row.map((_, colIdx) => dirty?.has(colIdx) ?? false);
@@ -989,7 +1015,7 @@ const displayItems = computed<RowItem[]>(() => {
});
const hasVisibleRows = computed(() => displayItems.value.length > 0);
const hasActiveFilter = computed(
() => !!clientSearchText.value || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value,
() => !!deferredClientSearchText.value || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value,
);
const totalFilterableRowCount = computed(() => props.result.rows.length + newRows.value.length);
const emptyTitle = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRows") : t("grid.noRows")));
@@ -2130,6 +2156,7 @@ onUnmounted(() => {
if (cancelScrollRestoreFrame) cancelAnimationFrame(cancelScrollRestoreFrame);
onDdlResizeEnd();
finishCellSelection();
clearTimeout(_searchTimer);
});
const SQL_KEYWORDS =
+22 -23
View File
@@ -1112,52 +1112,51 @@ function openFieldLineage() {
}
const canExpand = !leafTypes.has(props.node.type);
const nodeConfig = computed(() =>
props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined,
);
const canPin = computed(() => pinnableTypes.has(props.node.type));
const canOpenSqlFileExecution = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return !!config && !SQL_FILE_UNSUPPORTED_TYPES.has(config.db_type);
return !!nodeConfig.value && !SQL_FILE_UNSUPPORTED_TYPES.has(nodeConfig.value.db_type);
});
const canOpenDiagram = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return !!props.node.database && !!config && DIAGRAM_SUPPORTED_TYPES.has(config.db_type);
return !!props.node.database && !!nodeConfig.value && DIAGRAM_SUPPORTED_TYPES.has(nodeConfig.value.db_type);
});
const canOpenDatabaseSearch = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return !!props.node.database && !!config && DATABASE_SEARCH_SUPPORTED_TYPES.has(config.db_type);
return !!props.node.database && !!nodeConfig.value && DATABASE_SEARCH_SUPPORTED_TYPES.has(nodeConfig.value.db_type);
});
const canOpenObjectBrowser = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return (
(props.node.type === "database" || props.node.type === "schema" || props.node.type === "object-browser") &&
!!config &&
config.db_type !== "redis" &&
config.db_type !== "mongodb" &&
config.db_type !== "elasticsearch"
!!nodeConfig.value &&
nodeConfig.value.db_type !== "redis" &&
nodeConfig.value.db_type !== "mongodb" &&
nodeConfig.value.db_type !== "elasticsearch"
);
});
const canOpenTableImport = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return (
props.node.type === "table" && !!props.node.database && !!config && TABLE_IMPORT_SUPPORTED_TYPES.has(config.db_type)
);
});
const canOpenStructureEditor = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return (
props.node.type === "table" &&
!!props.node.database &&
!!config &&
TABLE_STRUCTURE_SUPPORTED_TYPES.has(config.db_type)
!!nodeConfig.value &&
TABLE_IMPORT_SUPPORTED_TYPES.has(nodeConfig.value.db_type)
);
});
const canOpenStructureEditor = computed(() => {
return (
props.node.type === "table" &&
!!props.node.database &&
!!nodeConfig.value &&
TABLE_STRUCTURE_SUPPORTED_TYPES.has(nodeConfig.value.db_type)
);
});
const canOpenFieldLineage = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return (
props.node.type === "column" &&
!!props.node.database &&
!!props.node.tableName &&
!!config &&
FIELD_LINEAGE_SUPPORTED_TYPES.has(config.db_type)
!!nodeConfig.value &&
FIELD_LINEAGE_SUPPORTED_TYPES.has(nodeConfig.value.db_type)
);
});
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));