diff --git a/api-docs/openapi.yaml b/api-docs/openapi.yaml index f83a88c5..3ba93a71 100644 --- a/api-docs/openapi.yaml +++ b/api-docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: DBackup API - version: 1.2.0 + version: 1.2.1 description: | REST API for DBackup — a self-hosted database backup automation platform with encryption, compression, and smart retention. diff --git a/package.json b/package.json index 000a3def..7c7de6ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dbackup", - "version": "1.2.0", + "version": "1.2.1", "private": true, "scripts": { "dev": "next dev", diff --git a/public/openapi.yaml b/public/openapi.yaml index f83a88c5..3ba93a71 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: DBackup API - version: 1.2.0 + version: 1.2.1 description: | REST API for DBackup — a self-hosted database backup automation platform with encryption, compression, and smart retention. diff --git a/src/app/api/executions/[id]/cancel/route.ts b/src/app/api/executions/[id]/cancel/route.ts new file mode 100644 index 00000000..95c520ef --- /dev/null +++ b/src/app/api/executions/[id]/cancel/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { headers } from "next/headers"; +import { getAuthContext, checkPermissionWithContext } from "@/lib/access-control"; +import { PERMISSIONS } from "@/lib/permissions"; +import { abortExecution, isExecutionRunning } from "@/lib/execution-abort"; + +/** + * POST /api/executions/[id]/cancel + * + * Cancel a running or pending execution. + */ +export async function POST( + _req: NextRequest, + props: { params: Promise<{ id: string }> } +) { + const ctx = await getAuthContext(await headers()); + + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + checkPermissionWithContext(ctx, PERMISSIONS.JOBS.EXECUTE); + + const { id } = await props.params; + + const execution = await prisma.execution.findUnique({ + where: { id }, + select: { id: true, status: true }, + }); + + if (!execution) { + return NextResponse.json({ success: false, error: "Execution not found" }, { status: 404 }); + } + + // Cancel pending executions directly (not yet running) + if (execution.status === "Pending") { + await prisma.execution.update({ + where: { id }, + data: { + status: "Cancelled", + endedAt: new Date(), + logs: JSON.stringify([{ + timestamp: new Date().toISOString(), + level: "warn", + type: "general", + message: "Execution was cancelled by user before it started", + stage: "Cancelled", + }]), + metadata: JSON.stringify({ progress: 0, stage: "Cancelled" }), + }, + }); + return NextResponse.json({ success: true, message: "Pending execution cancelled" }); + } + + // Cancel running executions via abort signal (or DB fallback if not tracked in this process) + if (execution.status === "Running") { + // Try in-memory abort first (works when execution runs in this process) + if (isExecutionRunning(id)) { + const aborted = abortExecution(id); + if (aborted) { + return NextResponse.json({ success: true, message: "Cancellation signal sent" }); + } + } + + // Fallback: execution not tracked in memory (e.g. HMR reload, different process). + // Force cancel via direct DB update. + const current = await prisma.execution.findUnique({ + where: { id }, + select: { logs: true }, + }); + + let logs: Array> = []; + try { + logs = current?.logs ? JSON.parse(current.logs as string) : []; + } catch { /* ignore parse errors */ } + + logs.push({ + timestamp: new Date().toISOString(), + level: "warn", + type: "general", + message: "Execution was force-cancelled by user (process not tracked)", + stage: "Cancelled", + }); + + await prisma.execution.update({ + where: { id }, + data: { + status: "Cancelled", + endedAt: new Date(), + logs: JSON.stringify(logs), + metadata: JSON.stringify({ progress: 0, stage: "Cancelled" }), + }, + }); + + return NextResponse.json({ success: true, message: "Execution force-cancelled" }); + } + + return NextResponse.json( + { success: false, error: `Cannot cancel execution with status: ${execution.status}` }, + { status: 400 } + ); +} diff --git a/src/app/dashboard/history/columns.tsx b/src/app/dashboard/history/columns.tsx index 5f7b7c07..13e2df21 100644 --- a/src/app/dashboard/history/columns.tsx +++ b/src/app/dashboard/history/columns.tsx @@ -14,7 +14,7 @@ export interface Execution { name: string; }; type?: string; - status: "Running" | "Success" | "Failed" | "Pending" | "Partial"; + status: "Running" | "Success" | "Failed" | "Pending" | "Partial" | "Cancelled"; startedAt: string; endedAt?: string; logs: string; // JSON string @@ -78,6 +78,12 @@ export const createColumns = (onViewLogs: (execution: Execution) => void): Colum Running ); + } else if (status === "Cancelled") { + return ( + + Cancelled + + ); } return {status}; diff --git a/src/app/dashboard/history/page.tsx b/src/app/dashboard/history/page.tsx index a03414eb..cebc78fe 100644 --- a/src/app/dashboard/history/page.tsx +++ b/src/app/dashboard/history/page.tsx @@ -13,13 +13,15 @@ import { createColumns, Execution } from "./columns"; import { createNotificationLogColumns, NotificationLogRow } from "./notification-log-columns"; import { NotificationPreview } from "./notification-preview"; import { useSearchParams, useRouter } from "next/navigation"; -import { Loader2 } from "lucide-react"; +import { Loader2, Square } from "lucide-react"; import { Progress } from "@/components/ui/progress"; import { DateDisplay } from "@/components/utils/date-display"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LogViewer } from "@/components/execution/log-viewer"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; export default function HistoryPage() { return ( @@ -35,6 +37,7 @@ function HistoryContent() { // Notification log state const [notificationLogs, setNotificationLogs] = useState([]); const [selectedNotification, setSelectedNotification] = useState(null); + const [isCancelling, setIsCancelling] = useState(false); const searchParams = useSearchParams(); const router = useRouter(); @@ -122,6 +125,26 @@ function HistoryContent() { } }; + const handleCancelExecution = useCallback(async (executionId: string) => { + setIsCancelling(true); + try { + const res = await fetch(`/api/executions/${encodeURIComponent(executionId)}/cancel`, { + method: "POST", + }); + const data = await res.json(); + if (data.success) { + toast.success("Cancellation signal sent"); + fetchHistory(); + } else { + toast.error(data.error || "Failed to cancel execution"); + } + } catch { + toast.error("Failed to cancel execution"); + } finally { + setIsCancelling(false); + } + }, [fetchHistory]); + const columns = useMemo(() => createColumns(setSelectedLog), []); const notificationColumns = useMemo( () => createNotificationLogColumns(setSelectedNotification), @@ -144,6 +167,7 @@ function HistoryContent() { { label: "Success", value: "Success" }, { label: "Failed", value: "Failed" }, { label: "Running", value: "Running" }, + { label: "Cancelled", value: "Cancelled" }, ] }, ], []); @@ -253,7 +277,7 @@ function HistoryContent() { {selectedLog?.status === "Running" && } {selectedLog?.job?.name || selectedLog?.type || "Manual Job"} {selectedLog?.status && ( - + {selectedLog.status} )} @@ -263,18 +287,36 @@ function HistoryContent() { - {selectedLog?.status === "Running" && ( + {(selectedLog?.status === "Running" || selectedLog?.status === "Pending") && (
-
- {stage} - {progress > 0 ? `${progress}%` : ''} -
- {progress > 0 ? ( - - ) : ( -
-
+
+
+ {selectedLog?.status === "Pending" ? "Waiting in queue..." : stage} + {selectedLog?.status === "Running" && progress > 0 && {progress}%}
+ +
+ {selectedLog?.status === "Running" && ( + progress > 0 ? ( + + ) : ( +
+
+
+ ) )}
)} diff --git a/src/components/dashboard/explorer/database-explorer.tsx b/src/components/dashboard/explorer/database-explorer.tsx index 9599bf7c..29d80f00 100644 --- a/src/components/dashboard/explorer/database-explorer.tsx +++ b/src/components/dashboard/explorer/database-explorer.tsx @@ -60,24 +60,17 @@ export function DatabaseExplorer({ sources }: DatabaseExplorerProps) { setDatabases([]); setServerVersion(null); - // Fetch version in parallel with stats - const versionPromise = fetch("/api/adapters/test-connection", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ configId: sourceId }), - }).then((r) => r.json()).catch(() => null); - - const statsPromise = fetch("/api/adapters/database-stats", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sourceId }), - }).then((r) => r.json()); - try { - const [versionData, statsData] = await Promise.all([versionPromise, statsPromise]); + // database-stats endpoint returns both databases and server version + const res = await fetch("/api/adapters/database-stats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceId }), + }); + const statsData = await res.json(); - if (versionData?.version) { - setServerVersion(versionData.version); + if (statsData.serverVersion) { + setServerVersion(statsData.serverVersion); } if (statsData.success && statsData.databases) { diff --git a/src/components/dashboard/widgets/activity-chart.tsx b/src/components/dashboard/widgets/activity-chart.tsx index 97e52eec..c29888eb 100644 --- a/src/components/dashboard/widgets/activity-chart.tsx +++ b/src/components/dashboard/widgets/activity-chart.tsx @@ -29,6 +29,10 @@ const chartConfig = { label: "Pending", color: "hsl(45, 93%, 58%)", }, + cancelled: { + label: "Cancelled", + color: "hsl(0, 0%, 55%)", + }, } satisfies ChartConfig; interface ActivityChartProps { @@ -37,7 +41,7 @@ interface ActivityChartProps { export function ActivityChart({ data }: ActivityChartProps) { const hasData = data.some( - (d) => d.completed > 0 || d.failed > 0 || d.running > 0 || d.pending > 0 + (d) => d.completed > 0 || d.failed > 0 || d.running > 0 || d.pending > 0 || d.cancelled > 0 ); return ( @@ -89,6 +93,12 @@ export function ActivityChart({ data }: ActivityChartProps) { dataKey="pending" stackId="a" fill="var(--color-pending)" + radius={[0, 0, 0, 0]} + /> + diff --git a/src/components/dashboard/widgets/job-status-chart.tsx b/src/components/dashboard/widgets/job-status-chart.tsx index 1de5a34b..5d63f1df 100644 --- a/src/components/dashboard/widgets/job-status-chart.tsx +++ b/src/components/dashboard/widgets/job-status-chart.tsx @@ -33,6 +33,10 @@ const chartConfig = { label: "Pending", color: "hsl(45, 93%, 58%)", }, + Cancelled: { + label: "Cancelled", + color: "hsl(0, 0%, 55%)", + }, } satisfies ChartConfig; interface JobStatusChartProps { diff --git a/src/components/dashboard/widgets/latest-jobs.tsx b/src/components/dashboard/widgets/latest-jobs.tsx index e1322548..c22cfaeb 100644 --- a/src/components/dashboard/widgets/latest-jobs.tsx +++ b/src/components/dashboard/widgets/latest-jobs.tsx @@ -55,6 +55,7 @@ export function LatestJobs({ data }: LatestJobsProps) { const isRunning = job.status === "Running"; const isSuccess = job.status === "Success"; const isPending = job.status === "Pending"; + const isCancelled = job.status === "Cancelled"; return (
- +

{job.jobName} @@ -120,11 +121,13 @@ function SourceIcon({ isRunning, isSuccess, isPending, + isCancelled, }: { sourceType: string | null; isRunning: boolean; isSuccess: boolean; isPending: boolean; + isCancelled: boolean; }) { const className = `h-4 w-4 shrink-0 ${ isRunning @@ -133,7 +136,9 @@ function SourceIcon({ ? "text-green-500" : isPending ? "text-yellow-500" - : "text-red-500" + : isCancelled + ? "text-muted-foreground" + : "text-red-500" }`; if (isRunning) return ; @@ -147,6 +152,7 @@ function StatusBadge({ status }: { status: string }) { Failed: { bg: "bg-[hsl(357,78%,54%)]", label: "Failed" }, Running: { bg: "bg-[hsl(225,79%,54%)]", label: "Running" }, Pending: { bg: "bg-[hsl(45,93%,58%)]", label: "Pending" }, + Cancelled: { bg: "bg-[hsl(0,0%,55%)]", label: "Cancelled" }, }; const { bg, label } = config[status] ?? { bg: "bg-muted", label: status }; diff --git a/src/lib/adapters/database/mssql/connection.ts b/src/lib/adapters/database/mssql/connection.ts index 6ea2711a..c5e25931 100644 --- a/src/lib/adapters/database/mssql/connection.ts +++ b/src/lib/adapters/database/mssql/connection.ts @@ -33,7 +33,8 @@ export async function test(config: MSSQLConfig): Promise<{ success: boolean; mes try { const connConfig = buildConnectionConfig(config); - pool = await sql.connect(connConfig); + pool = new sql.ConnectionPool(connConfig); + await pool.connect(); // Get version and edition information const result = await pool.request().query(` @@ -114,7 +115,8 @@ export async function getDatabases(config: MSSQLConfig): Promise { try { const connConfig = buildConnectionConfig(config); - pool = await sql.connect(connConfig); + pool = new sql.ConnectionPool(connConfig); + await pool.connect(); // Exclude system databases (database_id <= 4: master, tempdb, model, msdb) const result = await pool.request().query(` @@ -146,7 +148,8 @@ export async function getDatabasesWithStats(config: MSSQLConfig): Promise void ): Promise { let pool: sql.ConnectionPool | null = null; const messages: SqlServerMessage[] = []; @@ -256,13 +265,19 @@ export async function executeQueryWithMessages( if (database) { connConfig.database = database; } + // Allow callers to override requestTimeout (e.g. 0 for long-running BACKUP/RESTORE) + if (requestTimeout !== undefined && connConfig.options) { + connConfig.options.requestTimeout = requestTimeout; + } - pool = await sql.connect(connConfig); + pool = new sql.ConnectionPool(connConfig); + await pool.connect(); const request = pool.request(); // Capture all SQL Server info messages (progress reports, warnings, errors) request.on("info", (info: SqlServerMessage) => { messages.push(info); + if (onMessage) onMessage(info); }); const result = await request.query(query); @@ -324,7 +339,8 @@ export async function executeParameterizedQuery( connConfig.database = database; } - pool = await sql.connect(connConfig); + pool = new sql.ConnectionPool(connConfig); + await pool.connect(); const request = pool.request(); // Add parameters to the request diff --git a/src/lib/adapters/database/mssql/dump.ts b/src/lib/adapters/database/mssql/dump.ts index e2b6b68c..bf4a88ff 100644 --- a/src/lib/adapters/database/mssql/dump.ts +++ b/src/lib/adapters/database/mssql/dump.ts @@ -118,15 +118,14 @@ export async function dump( log(`Executing backup`, "info", "command", backupQuery); - // Execute backup command on the server, capturing all SQL Server messages - const { messages } = await executeQueryWithMessages(config, backupQuery); - - // Log SQL Server progress/info messages (e.g. "10 percent processed") - for (const msg of messages) { + // Execute backup command on the server, capturing all SQL Server messages. + // Use requestTimeout=0 (no timeout) — large DB backups can run for hours. + // Stream progress messages in real-time so the UI shows live updates. + await executeQueryWithMessages(config, backupQuery, undefined, 0, (msg) => { if (msg.message) { log(`SQL Server: ${msg.message}`, "info", "general"); } - } + }); log(`Backup completed for: ${dbName}`); tempFiles.push({ server: serverBakPath, local: localBakPath }); diff --git a/src/lib/adapters/database/mssql/restore.ts b/src/lib/adapters/database/mssql/restore.ts index f8e7f204..4b7300ec 100644 --- a/src/lib/adapters/database/mssql/restore.ts +++ b/src/lib/adapters/database/mssql/restore.ts @@ -228,14 +228,13 @@ export async function restore( log(`Executing restore`, "info", "command", restoreQuery); try { - const { messages } = await executeQueryWithMessages(config, restoreQuery); - - // Log SQL Server progress/info messages - for (const msg of messages) { + // Use requestTimeout=0 (no timeout) — large DB restores can run for hours. + // Stream progress messages in real-time so the UI shows live updates. + await executeQueryWithMessages(config, restoreQuery, undefined, 0, (msg) => { if (msg.message) { log(`SQL Server: ${msg.message}`, "info", "general"); } - } + }); log(`Restore completed for: ${targetDb.target}`); } catch (error: unknown) { diff --git a/src/lib/execution-abort.ts b/src/lib/execution-abort.ts new file mode 100644 index 00000000..983f5720 --- /dev/null +++ b/src/lib/execution-abort.ts @@ -0,0 +1,43 @@ +/** + * Centralized abort registry for running executions (backup & restore). + * Allows cancellation of any running execution from the UI. + */ + +const controllers = new Map(); + +/** + * Register a new execution with an AbortController. + * Returns the AbortController's signal for the caller to use. + */ +export function registerExecution(executionId: string): AbortController { + const controller = new AbortController(); + controllers.set(executionId, controller); + return controller; +} + +/** + * Unregister an execution (called when execution finishes). + */ +export function unregisterExecution(executionId: string): void { + controllers.delete(executionId); +} + +/** + * Abort a running execution by ID. + * Returns true if the execution was found and signalled. + */ +export function abortExecution(executionId: string): boolean { + const controller = controllers.get(executionId); + if (controller) { + controller.abort(); + return true; + } + return false; +} + +/** + * Check if an execution is currently running in this process. + */ +export function isExecutionRunning(executionId: string): boolean { + return controllers.has(executionId); +} diff --git a/src/lib/runner.ts b/src/lib/runner.ts index 5de02557..7de1a515 100644 --- a/src/lib/runner.ts +++ b/src/lib/runner.ts @@ -9,6 +9,7 @@ import { processQueue } from "@/lib/queue-manager"; import { LogEntry, LogLevel, LogType } from "@/lib/core/logs"; import { logger } from "@/lib/logger"; import { wrapError } from "@/lib/errors"; +import { registerExecution, unregisterExecution } from "@/lib/execution-abort"; const log = logger.child({ module: "Runner" }); @@ -57,6 +58,9 @@ export async function performExecution(executionId: string, jobId: string) { const jobLog = logger.child({ module: "Runner", jobId, executionId }); jobLog.info("Starting execution"); + // Set up cancellation + const abortController = registerExecution(executionId); + // 1. Mark as RUNNING const initialExe = await prisma.execution.update({ where: { id: executionId }, @@ -187,7 +191,15 @@ export async function performExecution(executionId: string, jobId: string) { status: "Running", startedAt: new Date(), execution: initialExe as any, - destinations: [] + destinations: [], + abortSignal: abortController.signal, + }; + + // Helper: throw if cancellation was requested + const checkCancelled = () => { + if (abortController.signal.aborted) { + throw new Error("Execution was cancelled by user"); + } }; try { @@ -196,13 +208,16 @@ export async function performExecution(executionId: string, jobId: string) { // 1. Initialize (Loads Job Data, Adapters) // This will update ctx.job and refresh ctx.execution await stepInitialize(ctx); + checkCancelled(); updateProgress(0, "Dumping Database"); // 2. Dump await stepExecuteDump(ctx); + checkCancelled(); // 3. Upload (Stage will be set inside stepUpload to correctly distinguish processing/uploading) await stepUpload(ctx); + checkCancelled(); updateProgress(90, "Applying Retention Policy"); // 4. Retention @@ -220,11 +235,21 @@ export async function performExecution(executionId: string, jobId: string) { } catch (error) { const wrapped = wrapError(error); - ctx.status = "Failed"; - logEntry(`ERROR: ${wrapped.message}`); - jobLog.error("Execution failed", {}, wrapped); + // Distinguish cancellation from real failures + if (abortController.signal.aborted) { + ctx.status = "Cancelled"; + logEntry("Execution was cancelled by user", "warning"); + jobLog.info("Execution cancelled by user"); + } else { + ctx.status = "Failed"; + logEntry(`ERROR: ${wrapped.message}`); + jobLog.error("Execution failed", {}, wrapped); + } await flushLogs(executionId, true); } finally { + // Remove from running executions map + unregisterExecution(executionId); + // 4. Cleanup & Final Update (sets EndTime, Status in DB) await stepCleanup(ctx); await stepFinalize(ctx); diff --git a/src/lib/runner/types.ts b/src/lib/runner/types.ts index b283a034..3f3c2a2b 100644 --- a/src/lib/runner/types.ts +++ b/src/lib/runner/types.ts @@ -49,6 +49,9 @@ export interface RunnerContext { dumpSize?: number; metadata?: any; - status: "Success" | "Failed" | "Running" | "Partial"; + status: "Success" | "Failed" | "Running" | "Partial" | "Cancelled"; startedAt: Date; + + // Cancellation support + abortSignal?: AbortSignal; } diff --git a/src/services/dashboard-service.ts b/src/services/dashboard-service.ts index 04f90cb0..453e5a0e 100644 --- a/src/services/dashboard-service.ts +++ b/src/services/dashboard-service.ts @@ -24,6 +24,7 @@ export interface ActivityDataPoint { failed: number; running: number; pending: number; + cancelled: number; } export interface JobStatusDistribution { @@ -135,7 +136,7 @@ export async function getActivityData(days: number = 14): Promise { svcLog.error("Background restore failed", { executionId }, wrapError(err)); + }).finally(() => { + unregisterExecution(executionId); }); return { success: true, executionId, message: "Restore started" }; @@ -162,6 +165,7 @@ export class RestoreService { const { storageConfigId, file, targetSourceId, targetDatabaseName, databaseMapping, privilegedAuth } = input; let tempFile: string | null = null; const restoreStartTime = Date.now(); + const abortController = registerExecution(executionId); // Log Buffer const internalLogs: LogEntry[] = [{ @@ -670,30 +674,42 @@ export class RestoreService { } } catch (error: unknown) { - svcLog.error("Restore service error", {}, wrapError(error)); - log(`Fatal Error: ${getErrorMessage(error)}`, 'error'); - updateProgress(100, "Failed"); + // Distinguish cancellation from real failures + if (abortController.signal.aborted) { + svcLog.info("Restore cancelled by user", { executionId }); + log("Restore was cancelled by user", 'warning'); + updateProgress(100, "Cancelled"); - await prisma.execution.update({ - where: { id: executionId }, - data: { status: 'Failed', endedAt: new Date(), logs: JSON.stringify(internalLogs) } - }); + await prisma.execution.update({ + where: { id: executionId }, + data: { status: 'Cancelled', endedAt: new Date(), logs: JSON.stringify(internalLogs) } + }); + } else { + svcLog.error("Restore service error", {}, wrapError(error)); + log(`Fatal Error: ${getErrorMessage(error)}`, 'error'); + updateProgress(100, "Failed"); + + await prisma.execution.update({ + where: { id: executionId }, + data: { status: 'Failed', endedAt: new Date(), logs: JSON.stringify(internalLogs) } + }); - // System notification (fire-and-forget) - notify({ - eventType: NOTIFICATION_EVENTS.RESTORE_FAILURE, - data: { - sourceName: resolvedSourceName ?? targetSourceId, - databaseType: resolvedSourceType, - targetDatabase: targetDatabaseName, - backupFile: path.basename(file), - storageName: resolvedStorageName, - error: getErrorMessage(error), + // System notification (fire-and-forget) + notify({ + eventType: NOTIFICATION_EVENTS.RESTORE_FAILURE, + data: { + sourceName: resolvedSourceName ?? targetSourceId, + databaseType: resolvedSourceType, + targetDatabase: targetDatabaseName, + backupFile: path.basename(file), + storageName: resolvedStorageName, + error: getErrorMessage(error), duration: Date.now() - restoreStartTime, executionId, timestamp: new Date().toISOString(), }, }).catch(() => {}); + } } finally { if (tempFile) { await fs.promises.unlink(tempFile).catch(() => {}); diff --git a/wiki/changelog.md b/wiki/changelog.md index c17191fe..924d87e5 100644 --- a/wiki/changelog.md +++ b/wiki/changelog.md @@ -2,6 +2,32 @@ All notable changes to DBackup are documented here. +## v1.2.1 - Execution Cancellation, MSSQL Progress & Dashboard Polish +*Released: March 26, 2026* + +### ✨ Features + +- **execution**: Cancel running or pending executions from the live log dialog - a "Cancel" button now appears in the execution header when a backup or restore is in progress +- **execution**: New `Cancelled` status for executions - cancelled jobs are cleanly marked with proper log entries instead of showing as failed + +### 🐛 Bug Fixes + +- **mssql**: Fixed Database Explorer and Restore page showing 0 databases for MSSQL sources - replaced global singleton connection pool (`sql.connect()`) with independent per-operation pools (`new ConnectionPool()`) to prevent concurrent requests from closing each other's connections +- **mssql**: Fixed large database backups/restores hanging and timing out - `BACKUP DATABASE` and `RESTORE DATABASE` queries now run without request timeout (previously limited to 5 minutes, causing failures on databases >5 GB) +- **explorer**: Fixed Database Explorer not displaying server version - removed broken parallel `test-connection` call and now uses version info returned by `database-stats` endpoint + +### 🎨 Improvements + +- **mssql**: SQL Server progress messages (e.g. "10 percent processed") are now streamed to the execution log in real-time instead of only appearing after the backup/restore completes +- **dashboard**: All dashboard widgets (activity chart, job status donut, latest jobs list) now display the `Cancelled` status with a neutral gray color + +### 🐳 Docker + +- **Image**: `skyfay/dbackup:v1.2.1` +- **Also tagged as**: `latest`, `v1` +- **Platforms**: linux/amd64, linux/arm64 + + ## v1.2.0 - HTTPS by Default, Certificate Management & Per-Adapter Health Notifications *Released: March 25, 2026* diff --git a/wiki/package.json b/wiki/package.json index e97fe1b7..6cce0393 100644 --- a/wiki/package.json +++ b/wiki/package.json @@ -1,6 +1,6 @@ { "name": "dbackup-wiki", - "version": "1.2.0", + "version": "1.2.1", "private": true, "scripts": { "dev": "vitepress dev",