fix(export): keep MySQL database export snapshots alive

This commit is contained in:
zipg authored and GitHub committed 2026-08-06 16:32:39 +08:00
1 parent 71a3b383d3
commit 9d348f8ef7
17 files changed
+290 -35

No files matched your search

@@ -68,7 +68,10 @@ const progressValue = (task: ExportTask) => {
};
const taskTitle = (task: ExportTask) => {
if (task.kind === "database-export") return t("exportProgress.databaseExportTitle", { name: task.tableName });
if (task.kind === "database-export") {
const key = task.databaseExportSource === "scheduled" ? "exportProgress.databaseBackupTitle" : "exportProgress.databaseExportTitle";
return t(key, { name: task.tableName });
}
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
return `${task.tableName}.${task.format}`;
@@ -113,6 +116,18 @@ const elapsedText = (task: ExportTask) => {
return t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - task.startedAt) });
};
const databaseObjectText = (task: ExportTask) => {
if (task.kind !== "database-export" || !isActive(task.status) || !task.currentObject) return "";
if (task.preparing || !task.totalObjects) {
return t("databaseExport.preparingObject", { object: task.currentObject });
}
return t("databaseExport.currentTable", {
table: task.currentObject,
current: (task.objectIndex ?? 0).toLocaleString(),
total: task.totalObjects.toLocaleString(),
});
};
const statusIcon = (task: ExportTask) => {
if (isActive(task.status)) {
if (task.kind === "database-export") return DatabaseBackup;
@@ -172,7 +187,7 @@ function failureDetailCount(task: ExportTask) {
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="relative h-8 w-8" :title="triggerTitle" :class="{ 'bg-destructive/10 text-destructive hover:bg-destructive/15': failedCount > 0, 'bg-accent text-primary': failedCount === 0 && hasActive }">
<FileDown class="h-4 w-4" />
<span v-if="failedCount > 0" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold leading-none text-destructive-foreground"> ! </span>
<span v-if="failedCount > 0" class="absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-background" />
<span v-else-if="hasActive" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground">
{{ activeCount > 9 ? "9+" : activeCount }}
</span>
@@ -207,6 +222,10 @@ function failureDetailCount(task: ExportTask) {
<div class="h-full bg-green-500 rounded-full" style="width: 100%" />
</div>
<div v-if="databaseObjectText(task)" class="min-w-0 truncate text-muted-foreground" :title="task.currentObject">
{{ databaseObjectText(task) }}
</div>
<div class="min-w-0 text-muted-foreground">
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.kind !== 'data-transfer' && task.startedAt !== undefined" class="ml-1 tabular-nums">{{ elapsedText(task) }}</span>
@@ -67,6 +67,96 @@ async function mountPopover() {
}
describe("ExportProgressPopover task duration", () => {
it("distinguishes manual exports from scheduled backups and uses a compact failure dot", async () => {
const tracker = useExportTracker();
tracker.addDatabaseExportTask("manual-export", "app", "/tmp/app.sql");
tracker.addDatabaseExportTask("scheduled-backup", "Nightly", "/tmp/backups", "scheduled");
tracker.updateDatabaseExportTask("manual-export", {
exportId: "manual-export",
currentObject: "",
objectIndex: 0,
totalObjects: 0,
rowsExported: 0,
totalRows: null,
status: "Error",
error: "export failed",
});
await mountPopover();
expect(document.body.textContent).toContain("Database export: app");
expect(document.body.textContent).toContain("Database backup: Nightly");
const failureDot = document.body.querySelector<HTMLSpanElement>("button > span");
expect(failureDot?.classList.contains("h-2.5")).toBe(true);
expect(failureDot?.classList.contains("w-2.5")).toBe(true);
expect(failureDot?.classList.contains("right-0.5")).toBe(true);
expect(failureDot?.classList.contains("top-0.5")).toBe(true);
expect(failureDot?.textContent?.trim()).toBe("");
});
it("shows the current database export object without replacing the task title", async () => {
const tracker = useExportTracker();
const task = tracker.addDatabaseExportTask("database-export", "demo_2000_tables", "/tmp/demo.sql");
tracker.updateDatabaseExportTask(task.exportId, {
exportId: task.exportId,
currentObject: "t_0123_with_a_long_descriptive_name",
objectIndex: 123,
totalObjects: 2000,
rowsExported: 456,
totalRows: null,
status: "Running",
error: null,
preparing: false,
});
await mountPopover();
expect(document.body.textContent).toContain("Database export: demo_2000_tables");
expect(document.body.textContent).toContain("Current: t_0123_with_a_long_descriptive_name (123/2,000)");
expect(document.body.querySelector('[title="t_0123_with_a_long_descriptive_name"]')).not.toBeNull();
});
it("shows the current object while database export metadata is being prepared", async () => {
const tracker = useExportTracker();
const task = tracker.addDatabaseExportTask("preparing-export", "demo", "/tmp/demo.sql");
tracker.updateDatabaseExportTask(task.exportId, {
exportId: task.exportId,
currentObject: "t_0001",
objectIndex: 0,
totalObjects: 0,
rowsExported: 0,
totalRows: null,
status: "Running",
error: null,
preparing: true,
});
await mountPopover();
expect(document.body.textContent).toContain("Preparing: t_0001");
});
it.each(["Done", "Error", "Cancelled"] as const)("hides stale database object text after the task reaches %s", async (status) => {
const tracker = useExportTracker();
const task = tracker.addDatabaseExportTask(`terminal-${status}`, "Nightly", "/tmp/backups", "scheduled");
tracker.updateDatabaseExportTask(task.exportId, {
exportId: task.exportId,
currentObject: "Nightly",
objectIndex: 2,
totalObjects: status === "Error" ? 0 : 2,
rowsExported: 0,
totalRows: null,
status,
error: status === "Error" ? "backup failed" : null,
preparing: status === "Error",
});
await mountPopover();
expect(document.body.textContent).not.toContain("Preparing: Nightly");
expect(document.body.textContent).not.toContain("Current: Nightly");
});
it("shows frozen transfer elapsed time and live duration for table tasks", async () => {
const tracker = useExportTracker();
now = 1_000;
@@ -4,6 +4,7 @@ import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer";
export type BackgroundTaskStatus = "Running" | "Writing" | "Done" | "Error" | "Cancelled";
export type DatabaseExportSource = "manual" | "scheduled";
export interface DataTransferFailure {
table: string;
@@ -21,6 +22,9 @@ export interface ExportTask {
totalRows: number | null;
status: BackgroundTaskStatus;
errorMessage: string | null;
databaseExportSource?: DatabaseExportSource;
currentObject?: string;
preparing?: boolean;
objectIndex?: number;
totalObjects?: number;
overallPercent?: number;
@@ -254,7 +258,7 @@ export function useExportTracker() {
return task;
}
function addDatabaseExportTask(exportId: string, label: string, filePath: string): ExportTask {
function addDatabaseExportTask(exportId: string, label: string, filePath: string, databaseExportSource: DatabaseExportSource = "manual"): ExportTask {
const task = reactive<ExportTask>({
exportId,
kind: "database-export",
@@ -265,6 +269,9 @@ export function useExportTracker() {
totalRows: null,
status: "Running",
errorMessage: null,
databaseExportSource,
currentObject: "",
preparing: true,
objectIndex: 0,
totalObjects: 0,
startedAt: Date.now(),
@@ -392,10 +399,8 @@ export function useExportTracker() {
function updateDatabaseExportTask(exportId: string, progress: api.ExportProgress & { overallPercent?: number }) {
const task = taskMap.get(exportId);
if (!task) return;
// Keep the database label during metadata prefetch; only follow object names while writing.
if (!progress.preparing && progress.currentObject) {
task.tableName = progress.currentObject;
}
task.currentObject = progress.currentObject;
task.preparing = !!progress.preparing;
task.rowsExported = progress.rowsExported;
task.totalRows = progress.totalRows;
task.status = normalizeExportStatus(progress.status);
@@ -182,7 +182,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
activeScheduleIds.add(schedule.id);
activeRunIds.add(runId);
cancellationRequested.delete(runId);
addDatabaseExportTask(runId, schedule.name, schedule.destinationDirectory);
addDatabaseExportTask(runId, schedule.name, schedule.destinationDirectory, "scheduled");
registerTaskCancelHandler(runId, () => cancelRun(runId));
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
@@ -219,27 +219,29 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
finalStatus = "cancelled";
break;
}
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(schedule.connectionId, database) } : undefined;
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
schemasByDatabase,
});
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
for (const item of databasePlan) {
if (schedule.tableFilterMode === "all") {
scopedDatabasePlan.push(item);
continue;
}
const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name);
const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
includedTableCount += scope.includedTables.length;
if (scope.includedTables.length === 0) continue;
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
}
const snapshot = await api.beginDatabaseBackupSnapshot(schedule.connectionId, database);
let snapshotCompleted = false;
try {
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
schemasByDatabase: { [database]: snapshot.schemas },
});
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
for (const item of databasePlan) {
if (schedule.tableFilterMode === "all") {
scopedDatabasePlan.push(item);
continue;
}
const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name);
const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
includedTableCount += scope.includedTables.length;
if (scope.includedTables.length === 0) continue;
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
}
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
+2 -1
View File
@@ -1603,7 +1603,8 @@ export default {
showMore: "Show {count} more",
showLess: "Show less",
delete: "Remove",
databaseExportTitle: "Database backup: {name}",
databaseExportTitle: "Database export: {name}",
databaseBackupTitle: "Database backup: {name}",
sqlFileTitle: "SQL file: {name}",
dataTransferTitle: "Data transfer: {name}",
objectsCount: "{current} / {total} objects",
+2 -1
View File
@@ -1544,7 +1544,8 @@ export default withEnglishFallback({
showMore: "Mostrar {count} más",
showLess: "Mostrar menos",
failedTooltip: "{count} tarea en segundo plano fallida | {count} tareas en segundo plano fallidas",
databaseExportTitle: "Copia de seguridad: {name}",
databaseExportTitle: "Exportación de base de datos: {name}",
databaseBackupTitle: "Copia de seguridad: {name}",
sqlFileTitle: "Archivo SQL: {name}",
dataTransferTitle: "Transferencia de datos: {name}",
objectsCount: "{current} / {total} objetos",
+2 -1
View File
@@ -1543,7 +1543,8 @@ export default withEnglishFallback({
showMore: "Mostra altri {count}",
showLess: "Mostra meno",
delete: "Rimuovi",
databaseExportTitle: "Backup database: {name}",
databaseExportTitle: "Esportazione database: {name}",
databaseBackupTitle: "Backup database: {name}",
sqlFileTitle: "File SQL: {name}",
dataTransferTitle: "Trasferimento dati: {name}",
objectsCount: "{current} / {total} oggetti",
+2 -1
View File
@@ -1570,7 +1570,8 @@ export default withEnglishFallback({
showLess: "少なく表示",
delete: "削除",
failedTooltip: "{count}件のバックグラウンドタスクが失敗しました",
databaseExportTitle: "データベースバックアップ: {name}",
databaseExportTitle: "データベースエクスポート: {name}",
databaseBackupTitle: "データベースバックアップ: {name}",
sqlFileTitle: "SQLファイル: {name}",
dataTransferTitle: "データ転送: {name}",
objectsCount: "{current} / {total} オブジェクト",
+2 -1
View File
@@ -1498,7 +1498,8 @@ export default withEnglishFallback({
showMore: "{count}개 더 보기",
showLess: "줄여서 보기",
delete: "제거",
databaseExportTitle: "데이터베이스 백업: {name}",
databaseExportTitle: "데이터베이스 내보내기: {name}",
databaseBackupTitle: "데이터베이스 백업: {name}",
sqlFileTitle: "SQL 파일: {name}",
dataTransferTitle: "데이터 이전: {name}",
objectsCount: "{current} / {total} 객체",
+2 -1
View File
@@ -1545,7 +1545,8 @@ export default withEnglishFallback({
showMore: "Mostrar mais {count}",
showLess: "Mostrar menos",
delete: "Remover",
databaseExportTitle: "Backup de banco de dados: {name}",
databaseExportTitle: "Exportação de banco de dados: {name}",
databaseBackupTitle: "Backup de banco de dados: {name}",
sqlFileTitle: "Arquivo SQL: {name}",
dataTransferTitle: "Transferência de dados: {name}",
objectsCount: "{current} / {total} objetos",
+2 -1
View File
@@ -1604,7 +1604,8 @@ export default withEnglishFallback({
showMore: "展开其余 {count} 个",
showLess: "收起",
delete: "删除",
databaseExportTitle: "数据库备份:{name}",
databaseExportTitle: "数据库导出:{name}",
databaseBackupTitle: "数据库备份:{name}",
sqlFileTitle: "SQL 文件:{name}",
dataTransferTitle: "数据传输:{name}",
objectsCount: "{current} / {total} 个对象",
+2 -1
View File
@@ -1544,7 +1544,8 @@ export default withEnglishFallback({
showMore: "顯示 {count} 個更多",
showLess: "顯示較少",
delete: "移除",
databaseExportTitle: "資料庫備份:{name}",
databaseExportTitle: "資料庫匯出:{name}",
databaseBackupTitle: "資料庫備份:{name}",
sqlFileTitle: "SQL 檔案:{name}",
dataTransferTitle: "資料傳輸:{name}",
objectsCount: "{current} / {total} 個物件",