Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api-docs/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dbackup",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion public/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
103 changes: 103 additions & 0 deletions src/app/api/executions/[id]/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = [];
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 }
);
}
8 changes: 7 additions & 1 deletion src/app/dashboard/history/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +78,12 @@ export const createColumns = (onViewLogs: (execution: Execution) => void): Colum
Running
</Badge>
);
} else if (status === "Cancelled") {
return (
<Badge variant="outline" className="text-muted-foreground">
Cancelled
</Badge>
);
}

return <Badge variant="outline">{status}</Badge>;
Expand Down
66 changes: 54 additions & 12 deletions src/app/dashboard/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -35,6 +37,7 @@ function HistoryContent() {
// Notification log state
const [notificationLogs, setNotificationLogs] = useState<NotificationLogRow[]>([]);
const [selectedNotification, setSelectedNotification] = useState<NotificationLogRow | null>(null);
const [isCancelling, setIsCancelling] = useState(false);

const searchParams = useSearchParams();
const router = useRouter();
Expand Down Expand Up @@ -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),
Expand All @@ -144,6 +167,7 @@ function HistoryContent() {
{ label: "Success", value: "Success" },
{ label: "Failed", value: "Failed" },
{ label: "Running", value: "Running" },
{ label: "Cancelled", value: "Cancelled" },
]
},
], []);
Expand Down Expand Up @@ -253,7 +277,7 @@ function HistoryContent() {
{selectedLog?.status === "Running" && <Loader2 className="h-4 w-4 animate-spin text-blue-500 dark:text-blue-400" />}
<span className="font-mono">{selectedLog?.job?.name || selectedLog?.type || "Manual Job"}</span>
{selectedLog?.status && (
<Badge variant={selectedLog.status === 'Success' ? 'default' : selectedLog.status === 'Failed' ? 'destructive' : 'secondary'}>
<Badge variant={selectedLog.status === 'Success' ? 'default' : selectedLog.status === 'Failed' ? 'destructive' : selectedLog.status === 'Cancelled' ? 'outline' : 'secondary'}>
{selectedLog.status}
</Badge>
)}
Expand All @@ -263,18 +287,36 @@ function HistoryContent() {
</DialogDescription>
</DialogHeader>

{selectedLog?.status === "Running" && (
{(selectedLog?.status === "Running" || selectedLog?.status === "Pending") && (
<div className="px-6 py-3 bg-card/50 border-b border-border/50 shrink-0">
<div className="flex justify-between text-xs text-muted-foreground mb-2">
<span>{stage}</span>
<span>{progress > 0 ? `${progress}%` : ''}</span>
</div>
{progress > 0 ? (
<Progress value={progress} className="h-1.5 bg-muted" />
) : (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full w-full animate-indeterminate rounded-full bg-blue-500/50 origin-left-right"></div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{selectedLog?.status === "Pending" ? "Waiting in queue..." : stage}</span>
{selectedLog?.status === "Running" && progress > 0 && <span>{progress}%</span>}
</div>
<Button
variant="destructive"
size="sm"
onClick={() => selectedLog && handleCancelExecution(selectedLog.id)}
disabled={isCancelling}
className="h-7 text-xs"
>
{isCancelling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<Square className="h-3.5 w-3.5 mr-1.5" />
)}
Cancel
</Button>
</div>
{selectedLog?.status === "Running" && (
progress > 0 ? (
<Progress value={progress} className="h-1.5 bg-muted" />
) : (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full w-full animate-indeterminate rounded-full bg-blue-500/50 origin-left-right"></div>
</div>
)
)}
</div>
)}
Expand Down
25 changes: 9 additions & 16 deletions src/components/dashboard/explorer/database-explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 11 additions & 1 deletion src/components/dashboard/widgets/activity-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 (
Expand Down Expand Up @@ -89,6 +93,12 @@ export function ActivityChart({ data }: ActivityChartProps) {
dataKey="pending"
stackId="a"
fill="var(--color-pending)"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="cancelled"
stackId="a"
fill="var(--color-cancelled)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
Expand Down
4 changes: 4 additions & 0 deletions src/components/dashboard/widgets/job-status-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions src/components/dashboard/widgets/latest-jobs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Link
Expand All @@ -65,7 +66,7 @@ export function LatestJobs({ data }: LatestJobsProps) {
<div className="flex items-center justify-between hover:bg-muted/50 px-2 py-2.5 -mx-2 rounded-md transition-colors">
<div className="flex items-center gap-3 min-w-0">
<TypeBadge type={job.type} />
<SourceIcon sourceType={job.sourceType} isRunning={isRunning} isSuccess={isSuccess} isPending={isPending} />
<SourceIcon sourceType={job.sourceType} isRunning={isRunning} isSuccess={isSuccess} isPending={isPending} isCancelled={isCancelled} />
<div className="min-w-0">
<p className="text-sm font-medium leading-none truncate">
{job.jobName}
Expand Down Expand Up @@ -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
Expand All @@ -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 <Loader2 className={`${className} animate-spin`} />;
Expand All @@ -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 };
Expand Down
Loading
Loading