Add backup discovery tab with support for local and storage backups
- Add Backup model to Prisma schema with fields for container_id, server_id, hostname, backup info - Create backupService with discovery methods for local (/var/lib/vz/dump/) and storage (/mnt/pve/<storage>/dump/) backups - Add database methods for backup CRUD operations and grouping by container - Create backupsRouter with getAllBackupsGrouped and discoverBackups procedures - Add BackupsTab component with collapsible cards grouped by CT_ID and hostname - Integrate backups tab into main page navigation - Filter storages by node hostname matching to only show applicable storages - Skip PBS backups discovery (temporarily disabled) - Add comprehensive logging for backup discovery process
This commit is contained in:
@@ -41,6 +41,7 @@ model Server {
|
||||
ssh_key_path String?
|
||||
key_generated Boolean? @default(false)
|
||||
installed_scripts InstalledScript[]
|
||||
backups Backup[]
|
||||
|
||||
@@map("servers")
|
||||
}
|
||||
@@ -95,3 +96,22 @@ model LXCConfig {
|
||||
|
||||
@@map("lxc_configs")
|
||||
}
|
||||
|
||||
model Backup {
|
||||
id Int @id @default(autoincrement())
|
||||
container_id String
|
||||
server_id Int
|
||||
hostname String
|
||||
backup_name String
|
||||
backup_path String
|
||||
size BigInt?
|
||||
created_at DateTime?
|
||||
storage_name String
|
||||
storage_type String // 'local', 'storage', or 'pbs'
|
||||
discovered_at DateTime @default(now())
|
||||
server Server @relation(fields: [server_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([container_id])
|
||||
@@index([server_id])
|
||||
@@map("backups")
|
||||
}
|
||||
|
||||
260
src/app/_components/BackupsTab.tsx
Normal file
260
src/app/_components/BackupsTab.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server } from 'lucide-react';
|
||||
|
||||
interface Backup {
|
||||
id: number;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size: bigint | null;
|
||||
created_at: Date | null;
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server_name: string | null;
|
||||
server_color: string | null;
|
||||
}
|
||||
|
||||
interface ContainerBackups {
|
||||
container_id: string;
|
||||
hostname: string;
|
||||
backups: Backup[];
|
||||
}
|
||||
|
||||
export function BackupsTab() {
|
||||
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
|
||||
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
|
||||
|
||||
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const discoverMutation = api.backups.discoverBackups.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetchBackups();
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-discover backups when tab is first opened
|
||||
useEffect(() => {
|
||||
if (!hasAutoDiscovered && !isLoading && backupsData) {
|
||||
// Only auto-discover if there are no backups yet
|
||||
if (!backupsData.backups || backupsData.backups.length === 0) {
|
||||
handleDiscoverBackups();
|
||||
}
|
||||
setHasAutoDiscovered(true);
|
||||
}
|
||||
}, [hasAutoDiscovered, isLoading, backupsData]);
|
||||
|
||||
const handleDiscoverBackups = () => {
|
||||
discoverMutation.mutate();
|
||||
};
|
||||
|
||||
const toggleContainer = (containerId: string) => {
|
||||
const newExpanded = new Set(expandedContainers);
|
||||
if (newExpanded.has(containerId)) {
|
||||
newExpanded.delete(containerId);
|
||||
} else {
|
||||
newExpanded.add(containerId);
|
||||
}
|
||||
setExpandedContainers(newExpanded);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: bigint | null): string => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
const b = Number(bytes);
|
||||
if (b === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(b) / Math.log(k));
|
||||
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | null): string => {
|
||||
if (!date) return 'Unknown date';
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
const getStorageTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'pbs':
|
||||
return <Database className="h-4 w-4" />;
|
||||
case 'local':
|
||||
return <HardDrive className="h-4 w-4" />;
|
||||
default:
|
||||
return <Server className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
|
||||
switch (type) {
|
||||
case 'pbs':
|
||||
return 'default';
|
||||
case 'local':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const backups = backupsData?.success ? backupsData.backups : [];
|
||||
const isDiscovering = discoverMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with refresh button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">Backups</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Discovered backups grouped by container ID
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDiscoverBackups}
|
||||
disabled={isDiscovering}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||
{isDiscovering ? 'Discovering...' : 'Discover Backups'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{(isLoading || isDiscovering) && backups.length === 0 && (
|
||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isDiscovering && backups.length === 0 && (
|
||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Click "Discover Backups" to scan for backups on your servers.
|
||||
</p>
|
||||
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||
Discover Backups
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backups list */}
|
||||
{!isLoading && backups.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{backups.map((container: ContainerBackups) => {
|
||||
const isExpanded = expandedContainers.has(container.container_id);
|
||||
const backupCount = container.backups.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={container.container_id}
|
||||
className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Container header - collapsible */}
|
||||
<button
|
||||
onClick={() => toggleContainer(container.container_id)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-foreground">
|
||||
CT {container.container_id}
|
||||
</span>
|
||||
{container.hostname && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground">{container.hostname}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Container content - backups list */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border">
|
||||
<div className="p-4 space-y-3">
|
||||
{container.backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="bg-muted/50 rounded-lg p-4 border border-border/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="font-medium text-foreground break-all">
|
||||
{backup.backup_name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={getStorageTypeBadgeVariant(backup.storage_type)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{getStorageTypeIcon(backup.storage_type)}
|
||||
{backup.storage_name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
{backup.size && (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{formatFileSize(backup.size)}
|
||||
</span>
|
||||
)}
|
||||
{backup.created_at && (
|
||||
<span>{formatDate(backup.created_at)}</span>
|
||||
)}
|
||||
{backup.server_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
{backup.server_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<code className="text-xs text-muted-foreground break-all">
|
||||
{backup.backup_path}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{backupsData && !backupsData.success && (
|
||||
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
|
||||
<p className="text-destructive">
|
||||
Error loading backups: {backupsData.error || 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||
import { BackupsTab } from './_components/BackupsTab';
|
||||
import { ResyncButton } from './_components/ResyncButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||
@@ -16,16 +17,16 @@ import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
import { Footer } from './_components/Footer';
|
||||
import { Package, HardDrive, FolderOpen, LogOut } from 'lucide-react';
|
||||
import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useAuth } from './_components/AuthProvider';
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
|
||||
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
|
||||
return savedTab || 'scripts';
|
||||
}
|
||||
return 'scripts';
|
||||
@@ -38,6 +39,7 @@ export default function Home() {
|
||||
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||
|
||||
// Save active tab to localStorage whenever it changes
|
||||
@@ -118,7 +120,8 @@ export default function Home() {
|
||||
});
|
||||
}).length;
|
||||
})(),
|
||||
installed: installedScriptsData?.scripts?.length ?? 0
|
||||
installed: installedScriptsData?.scripts?.length ?? 0,
|
||||
backups: backupsData?.success ? backupsData.backups.length : 0
|
||||
};
|
||||
|
||||
const scrollToTerminal = () => {
|
||||
@@ -243,6 +246,22 @@ export default function Home() {
|
||||
</span>
|
||||
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('backups')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'backups'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
<Archive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Backups</span>
|
||||
<span className="sm:hidden">Backups</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
{scriptCounts.backups}
|
||||
</span>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,6 +292,10 @@ export default function Home() {
|
||||
{activeTab === 'installed' && (
|
||||
<InstalledScriptsTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'backups' && (
|
||||
<BackupsTab />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { scriptsRouter } from "~/server/api/routers/scripts";
|
||||
import { installedScriptsRouter } from "~/server/api/routers/installedScripts";
|
||||
import { serversRouter } from "~/server/api/routers/servers";
|
||||
import { versionRouter } from "~/server/api/routers/version";
|
||||
import { backupsRouter } from "~/server/api/routers/backups";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
|
||||
installedScripts: installedScriptsRouter,
|
||||
servers: serversRouter,
|
||||
version: versionRouter,
|
||||
backups: backupsRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
90
src/server/api/routers/backups.ts
Normal file
90
src/server/api/routers/backups.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, publicProcedure } from '~/server/api/trpc';
|
||||
import { getDatabase } from '~/server/database-prisma';
|
||||
import { getBackupService } from '~/server/services/backupService';
|
||||
|
||||
export const backupsRouter = createTRPCRouter({
|
||||
// Get all backups grouped by container ID
|
||||
getAllBackupsGrouped: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const groupedBackups = await db.getBackupsGroupedByContainer();
|
||||
|
||||
// Convert Map to array format for frontend
|
||||
const result: Array<{
|
||||
container_id: string;
|
||||
hostname: string;
|
||||
backups: Array<{
|
||||
id: number;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size: bigint | null;
|
||||
created_at: Date | null;
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server_name: string | null;
|
||||
server_color: string | null;
|
||||
}>;
|
||||
}> = [];
|
||||
|
||||
for (const [containerId, backups] of groupedBackups.entries()) {
|
||||
if (backups.length === 0) continue;
|
||||
|
||||
// Get hostname from first backup (all backups for same container should have same hostname)
|
||||
const hostname = backups[0]?.hostname || '';
|
||||
|
||||
result.push({
|
||||
container_id: containerId,
|
||||
hostname,
|
||||
backups: backups.map(backup => ({
|
||||
id: backup.id,
|
||||
backup_name: backup.backup_name,
|
||||
backup_path: backup.backup_path,
|
||||
size: backup.size,
|
||||
created_at: backup.created_at,
|
||||
storage_name: backup.storage_name,
|
||||
storage_type: backup.storage_type,
|
||||
discovered_at: backup.discovered_at,
|
||||
server_name: backup.server?.name ?? null,
|
||||
server_color: backup.server?.color ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
backups: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getAllBackupsGrouped:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch backups',
|
||||
backups: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Discover backups for all containers
|
||||
discoverBackups: publicProcedure
|
||||
.mutation(async () => {
|
||||
try {
|
||||
const backupService = getBackupService();
|
||||
await backupService.discoverAllBackups();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Backup discovery completed successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in discoverBackups:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to discover backups',
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2063,7 +2063,9 @@ EOFCONFIG`;
|
||||
|
||||
const storageService = getStorageService();
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = getSSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||
@@ -2076,16 +2078,62 @@ EOFCONFIG`;
|
||||
};
|
||||
}
|
||||
|
||||
// Get server hostname to filter storages
|
||||
let serverHostname = '';
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
'hostname',
|
||||
(data: string) => {
|
||||
serverHostname += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Failed to get hostname: ${error}`));
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`hostname command failed with exit code ${exitCode}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting server hostname:', error);
|
||||
// Continue without filtering if hostname can't be retrieved
|
||||
}
|
||||
|
||||
const normalizedHostname = serverHostname.trim().toLowerCase();
|
||||
|
||||
// Check if we have cached data
|
||||
const wasCached = !input.forceRefresh;
|
||||
|
||||
// Fetch storages (will use cache if not forcing refresh)
|
||||
const allStorages = await storageService.getStorages(server as Server, input.forceRefresh);
|
||||
|
||||
// Filter storages by node hostname matching
|
||||
const applicableStorages = allStorages.filter(storage => {
|
||||
// If storage has no nodes specified, it's available on all nodes
|
||||
if (!storage.nodes || storage.nodes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we couldn't get hostname, include all storages (fallback)
|
||||
if (!normalizedHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if server hostname is in the nodes array (case-insensitive, trimmed)
|
||||
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
|
||||
return normalizedNodes.includes(normalizedHostname);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
storages: allStorages,
|
||||
cached: wasCached && allStorages.length > 0
|
||||
storages: applicableStorages,
|
||||
cached: wasCached && applicableStorages.length > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getBackupStorages:', error);
|
||||
|
||||
@@ -271,6 +271,96 @@ class DatabaseServicePrisma {
|
||||
});
|
||||
}
|
||||
|
||||
// Backup CRUD operations
|
||||
async createOrUpdateBackup(backupData) {
|
||||
// Find existing backup by container_id, server_id, and backup_path
|
||||
const existing = await prisma.backup.findFirst({
|
||||
where: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
backup_path: backupData.backup_path,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing backup
|
||||
return await prisma.backup.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
hostname: backupData.hostname,
|
||||
backup_name: backupData.backup_name,
|
||||
size: backupData.size,
|
||||
created_at: backupData.created_at,
|
||||
storage_name: backupData.storage_name,
|
||||
storage_type: backupData.storage_type,
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new backup
|
||||
return await prisma.backup.create({
|
||||
data: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
hostname: backupData.hostname,
|
||||
backup_name: backupData.backup_name,
|
||||
backup_path: backupData.backup_path,
|
||||
size: backupData.size,
|
||||
created_at: backupData.created_at,
|
||||
storage_name: backupData.storage_name,
|
||||
storage_type: backupData.storage_type,
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAllBackups() {
|
||||
return await prisma.backup.findMany({
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ container_id: 'asc' },
|
||||
{ created_at: 'desc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsByContainerId(containerId) {
|
||||
return await prisma.backup.findMany({
|
||||
where: { container_id: containerId },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBackupsForContainer(containerId, serverId) {
|
||||
return await prisma.backup.deleteMany({
|
||||
where: {
|
||||
container_id: containerId,
|
||||
server_id: serverId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsGroupedByContainer() {
|
||||
const backups = await this.getAllBackups();
|
||||
const grouped = new Map();
|
||||
|
||||
for (const backup of backups) {
|
||||
const key = backup.container_id;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
grouped.get(key).push(backup);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
@@ -298,6 +298,125 @@ class DatabaseServicePrisma {
|
||||
});
|
||||
}
|
||||
|
||||
// Backup CRUD operations
|
||||
async createOrUpdateBackup(backupData: {
|
||||
container_id: string;
|
||||
server_id: number;
|
||||
hostname: string;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size?: bigint;
|
||||
created_at?: Date;
|
||||
storage_name: string;
|
||||
storage_type: 'local' | 'storage' | 'pbs';
|
||||
}) {
|
||||
// Find existing backup by container_id, server_id, and backup_path
|
||||
const existing = await prisma.backup.findFirst({
|
||||
where: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
backup_path: backupData.backup_path,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing backup
|
||||
return await prisma.backup.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
hostname: backupData.hostname,
|
||||
backup_name: backupData.backup_name,
|
||||
size: backupData.size,
|
||||
created_at: backupData.created_at,
|
||||
storage_name: backupData.storage_name,
|
||||
storage_type: backupData.storage_type,
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new backup
|
||||
return await prisma.backup.create({
|
||||
data: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
hostname: backupData.hostname,
|
||||
backup_name: backupData.backup_name,
|
||||
backup_path: backupData.backup_path,
|
||||
size: backupData.size,
|
||||
created_at: backupData.created_at,
|
||||
storage_name: backupData.storage_name,
|
||||
storage_type: backupData.storage_type,
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAllBackups() {
|
||||
return await prisma.backup.findMany({
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ container_id: 'asc' },
|
||||
{ created_at: 'desc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsByContainerId(containerId: string) {
|
||||
return await prisma.backup.findMany({
|
||||
where: { container_id: containerId },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBackupsForContainer(containerId: string, serverId: number) {
|
||||
return await prisma.backup.deleteMany({
|
||||
where: {
|
||||
container_id: containerId,
|
||||
server_id: serverId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsGroupedByContainer(): Promise<Map<string, Array<{
|
||||
id: number;
|
||||
container_id: string;
|
||||
server_id: number;
|
||||
hostname: string;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size: bigint | null;
|
||||
created_at: Date | null;
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server: {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
user: string;
|
||||
color: string | null;
|
||||
} | null;
|
||||
}>>> {
|
||||
const backups = await this.getAllBackups();
|
||||
const grouped = new Map<string, typeof backups>();
|
||||
|
||||
for (const backup of backups) {
|
||||
const key = backup.container_id;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
grouped.get(key)!.push(backup);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
534
src/server/services/backupService.ts
Normal file
534
src/server/services/backupService.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||
import { getStorageService } from './storageService';
|
||||
import { getDatabase } from '../database-prisma';
|
||||
import type { Server } from '~/types/server';
|
||||
import type { Storage } from './storageService';
|
||||
|
||||
export interface BackupData {
|
||||
container_id: string;
|
||||
server_id: number;
|
||||
hostname: string;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size?: bigint;
|
||||
created_at?: Date;
|
||||
storage_name: string;
|
||||
storage_type: 'local' | 'storage' | 'pbs';
|
||||
}
|
||||
|
||||
class BackupService {
|
||||
/**
|
||||
* Get server hostname via SSH
|
||||
*/
|
||||
async getServerHostname(server: Server): Promise<string> {
|
||||
const sshService = getSSHExecutionService();
|
||||
let hostname = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
'hostname',
|
||||
(data: string) => {
|
||||
hostname += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Failed to get hostname: ${error}`));
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`hostname command failed with exit code ${exitCode}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return hostname.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover local backups in /var/lib/vz/dump/
|
||||
*/
|
||||
async discoverLocalBackups(server: Server, ctId: string, hostname: string): Promise<BackupData[]> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const backups: BackupData[] = [];
|
||||
|
||||
// Find backup files matching pattern (with timeout)
|
||||
const findCommand = `timeout 10 find /var/lib/vz/dump/ -type f -name "vzdump-lxc-${ctId}-*.tar*" 2>/dev/null`;
|
||||
let findOutput = '';
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
findCommand,
|
||||
(data: string) => {
|
||||
findOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
// Ignore errors - directory might not exist
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 15000); // 15 second timeout
|
||||
})
|
||||
]);
|
||||
|
||||
const backupPaths = findOutput.trim().split('\n').filter(path => path.trim());
|
||||
|
||||
// Get detailed info for each backup file
|
||||
for (const backupPath of backupPaths) {
|
||||
if (!backupPath.trim()) continue;
|
||||
|
||||
try {
|
||||
// Get file size and modification time
|
||||
const statCommand = `stat -c "%s|%Y|%n" "${backupPath}" 2>/dev/null || stat -f "%z|%m|%N" "${backupPath}" 2>/dev/null || echo ""`;
|
||||
let statOutput = '';
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
statCommand,
|
||||
(data: string) => {
|
||||
statOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 5000); // 5 second timeout for stat
|
||||
})
|
||||
]);
|
||||
|
||||
const statParts = statOutput.trim().split('|');
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
|
||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||
const size = BigInt(statParts[0] || '0');
|
||||
const mtime = parseInt(statParts[1] || '0', 10);
|
||||
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size,
|
||||
created_at: mtime > 0 ? new Date(mtime * 1000) : undefined,
|
||||
storage_name: 'local',
|
||||
storage_type: 'local',
|
||||
});
|
||||
} else {
|
||||
// If stat fails, still add the backup with minimal info
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size: undefined,
|
||||
created_at: undefined,
|
||||
storage_name: 'local',
|
||||
storage_type: 'local',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Still try to add the backup even if stat fails
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size: undefined,
|
||||
created_at: undefined,
|
||||
storage_name: 'local',
|
||||
storage_type: 'local',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error discovering local backups for CT ${ctId}:`, error);
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover backups in mounted storage (/mnt/pve/<storage>/dump/)
|
||||
*/
|
||||
async discoverStorageBackups(server: Server, storage: Storage, ctId: string, hostname: string): Promise<BackupData[]> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const backups: BackupData[] = [];
|
||||
|
||||
const dumpPath = `/mnt/pve/${storage.name}/dump/`;
|
||||
const findCommand = `timeout 10 find "${dumpPath}" -type f -name "vzdump-lxc-${ctId}-*.tar*" 2>/dev/null`;
|
||||
let findOutput = '';
|
||||
|
||||
console.log(`[BackupService] Discovering storage backups for CT ${ctId} on ${storage.name}`);
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
findCommand,
|
||||
(data: string) => {
|
||||
findOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
// Ignore errors - storage might not be mounted
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log(`[BackupService] Storage backup discovery timeout for ${storage.name}`);
|
||||
resolve();
|
||||
}, 15000); // 15 second timeout
|
||||
})
|
||||
]);
|
||||
|
||||
const backupPaths = findOutput.trim().split('\n').filter(path => path.trim());
|
||||
console.log(`[BackupService] Found ${backupPaths.length} backup files for CT ${ctId} on storage ${storage.name}`);
|
||||
|
||||
// Get detailed info for each backup file
|
||||
for (const backupPath of backupPaths) {
|
||||
if (!backupPath.trim()) continue;
|
||||
|
||||
try {
|
||||
const statCommand = `stat -c "%s|%Y|%n" "${backupPath}" 2>/dev/null || stat -f "%z|%m|%N" "${backupPath}" 2>/dev/null || echo ""`;
|
||||
let statOutput = '';
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
statCommand,
|
||||
(data: string) => {
|
||||
statOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 5000); // 5 second timeout for stat
|
||||
})
|
||||
]);
|
||||
|
||||
const statParts = statOutput.trim().split('|');
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
|
||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||
const size = BigInt(statParts[0] || '0');
|
||||
const mtime = parseInt(statParts[1] || '0', 10);
|
||||
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size,
|
||||
created_at: mtime > 0 ? new Date(mtime * 1000) : undefined,
|
||||
storage_name: storage.name,
|
||||
storage_type: 'storage',
|
||||
});
|
||||
console.log(`[BackupService] Added storage backup: ${fileName} from ${storage.name}`);
|
||||
} else {
|
||||
// If stat fails, still add the backup with minimal info
|
||||
console.log(`[BackupService] Stat failed for ${fileName}, adding backup without size/date`);
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size: undefined,
|
||||
created_at: undefined,
|
||||
storage_name: storage.name,
|
||||
storage_type: 'storage',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing backup ${backupPath}:`, error);
|
||||
// Still try to add the backup even if stat fails
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size: undefined,
|
||||
created_at: undefined,
|
||||
storage_name: storage.name,
|
||||
storage_type: 'storage',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BackupService] Total storage backups found for CT ${ctId} on ${storage.name}: ${backups.length}`);
|
||||
} catch (error) {
|
||||
console.error(`Error discovering storage backups for CT ${ctId} on ${storage.name}:`, error);
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover PBS backups using proxmox-backup-client
|
||||
*/
|
||||
async discoverPBSBackups(server: Server, storage: Storage, ctId: string, hostname: string): Promise<BackupData[]> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const backups: BackupData[] = [];
|
||||
|
||||
// Use storage name as repository name (e.g., "PBS1")
|
||||
const repositoryName = storage.name;
|
||||
const command = `timeout 30 proxmox-backup-client snapshots host/${ctId} --repository ${repositoryName} 2>&1 || echo "PBS_ERROR"`;
|
||||
let output = '';
|
||||
|
||||
console.log(`[BackupService] Discovering PBS backups for CT ${ctId} on repository ${repositoryName}`);
|
||||
|
||||
try {
|
||||
// Add timeout to prevent hanging
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.log(`[BackupService] PBS command error for ${repositoryName}: ${error}`);
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.log(`[BackupService] PBS command completed for ${repositoryName} with exit code ${exitCode}`);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log(`[BackupService] PBS discovery timeout for ${repositoryName}, continuing...`);
|
||||
resolve();
|
||||
}, 35000); // 35 second timeout (command has 30s timeout, so this is a safety net)
|
||||
})
|
||||
]);
|
||||
|
||||
// Check if PBS command failed
|
||||
if (output.includes('PBS_ERROR') || output.includes('error') || output.includes('Error')) {
|
||||
console.log(`[BackupService] PBS discovery failed or no backups found for CT ${ctId} on ${repositoryName}`);
|
||||
return backups;
|
||||
}
|
||||
|
||||
// Parse PBS snapshot output
|
||||
// Format is typically: snapshot_name timestamp (optional size info)
|
||||
const lines = output.trim().split('\n').filter(line => line.trim());
|
||||
|
||||
console.log(`[BackupService] Parsing ${lines.length} lines from PBS output for ${repositoryName}`);
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines or error messages
|
||||
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse snapshot line - format varies, try to extract snapshot name and timestamp
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 0) {
|
||||
const snapshotName = parts[0];
|
||||
|
||||
// Try to extract timestamp if available
|
||||
let createdAt: Date | undefined;
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
const timestampMatch = parts[1].match(/\d+/);
|
||||
if (timestampMatch && timestampMatch[0]) {
|
||||
const timestamp = parseInt(timestampMatch[0], 10);
|
||||
// PBS timestamps might be in seconds or milliseconds
|
||||
createdAt = new Date(timestamp > 1000000000000 ? timestamp : timestamp * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: snapshotName || 'unknown',
|
||||
backup_path: `pbs://${repositoryName}/host/${ctId}/${snapshotName || 'unknown'}`,
|
||||
size: undefined, // PBS doesn't always provide size in snapshot list
|
||||
created_at: createdAt,
|
||||
storage_name: repositoryName,
|
||||
storage_type: 'pbs',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error discovering PBS backups for CT ${ctId} on ${repositoryName}:`, error);
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all backups for a container across all backup-capable storages
|
||||
*/
|
||||
async discoverAllBackupsForContainer(server: Server, ctId: string, hostname: string): Promise<BackupData[]> {
|
||||
const allBackups: BackupData[] = [];
|
||||
|
||||
try {
|
||||
// Get server hostname to filter storages
|
||||
const serverHostname = await this.getServerHostname(server);
|
||||
const normalizedHostname = serverHostname.trim().toLowerCase();
|
||||
console.log(`[BackupService] Discovering backups for server ${server.name} (hostname: ${serverHostname}, normalized: ${normalizedHostname})`);
|
||||
|
||||
// Get all backup-capable storages (force refresh to get latest node assignments)
|
||||
const storageService = getStorageService();
|
||||
const allStorages = await storageService.getBackupStorages(server, true); // Force refresh
|
||||
|
||||
console.log(`[BackupService] Found ${allStorages.length} backup-capable storages total`);
|
||||
|
||||
// Filter storages by node hostname matching
|
||||
const applicableStorages = allStorages.filter(storage => {
|
||||
// If storage has no nodes specified, it's available on all nodes
|
||||
if (!storage.nodes || storage.nodes.length === 0) {
|
||||
console.log(`[BackupService] Storage ${storage.name} has no nodes specified, including it`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize all nodes for comparison
|
||||
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
|
||||
const isApplicable = normalizedNodes.includes(normalizedHostname);
|
||||
|
||||
if (!isApplicable) {
|
||||
console.log(`[BackupService] EXCLUDING Storage ${storage.name} (nodes: ${storage.nodes.join(', ')}) - not applicable for hostname: ${serverHostname}`);
|
||||
} else {
|
||||
console.log(`[BackupService] INCLUDING Storage ${storage.name} (nodes: ${storage.nodes.join(', ')}) - applicable for hostname: ${serverHostname}`);
|
||||
}
|
||||
|
||||
return isApplicable;
|
||||
});
|
||||
|
||||
console.log(`[BackupService] Filtered to ${applicableStorages.length} applicable storages for ${serverHostname}`);
|
||||
|
||||
// Discover local backups
|
||||
const localBackups = await this.discoverLocalBackups(server, ctId, hostname);
|
||||
allBackups.push(...localBackups);
|
||||
|
||||
// Discover backups from each applicable storage
|
||||
for (const storage of applicableStorages) {
|
||||
try {
|
||||
if (storage.type === 'pbs') {
|
||||
// PBS storage
|
||||
const pbsBackups = await this.discoverPBSBackups(server, storage, ctId, hostname);
|
||||
allBackups.push(...pbsBackups);
|
||||
} else {
|
||||
// Regular storage (dir, nfs, etc.)
|
||||
const storageBackups = await this.discoverStorageBackups(server, storage, ctId, hostname);
|
||||
allBackups.push(...storageBackups);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BackupService] Error discovering backups from storage ${storage.name}:`, error);
|
||||
// Continue with other storages
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BackupService] Total backups discovered for CT ${ctId}: ${allBackups.length}`);
|
||||
} catch (error) {
|
||||
console.error(`Error discovering backups for container ${ctId}:`, error);
|
||||
}
|
||||
|
||||
return allBackups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover backups for all installed scripts with container_id
|
||||
*/
|
||||
async discoverAllBackups(): Promise<void> {
|
||||
const db = getDatabase();
|
||||
const scripts = await db.getAllInstalledScripts();
|
||||
|
||||
// Filter scripts that have container_id and server_id
|
||||
const scriptsWithContainers = scripts.filter(
|
||||
(script: any) => script.container_id && script.server_id && script.server
|
||||
);
|
||||
|
||||
// Clear all existing backups first to ensure we start fresh
|
||||
console.log('[BackupService] Clearing all existing backups before rediscovery...');
|
||||
const allBackups = await db.getAllBackups();
|
||||
for (const backup of allBackups) {
|
||||
await db.deleteBackupsForContainer(backup.container_id, backup.server_id);
|
||||
}
|
||||
console.log('[BackupService] Cleared all existing backups');
|
||||
|
||||
for (const script of scriptsWithContainers) {
|
||||
if (!script.container_id || !script.server_id || !script.server) continue;
|
||||
|
||||
const containerId = script.container_id;
|
||||
const serverId = script.server_id;
|
||||
const server = script.server as Server;
|
||||
|
||||
try {
|
||||
// Get hostname from LXC config if available, otherwise use script name
|
||||
let hostname = script.script_name || `CT-${script.container_id}`;
|
||||
try {
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.hostname) {
|
||||
hostname = lxcConfig.hostname;
|
||||
}
|
||||
} catch (error) {
|
||||
// LXC config might not exist, use script name
|
||||
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
|
||||
}
|
||||
|
||||
console.log(`[BackupService] Discovering backups for script ${script.id}, CT ${containerId} on server ${server.name}`);
|
||||
|
||||
// Discover backups for this container
|
||||
const backups = await this.discoverAllBackupsForContainer(
|
||||
server,
|
||||
containerId,
|
||||
hostname
|
||||
);
|
||||
|
||||
console.log(`[BackupService] Found ${backups.length} backups for CT ${containerId} on server ${server.name}`);
|
||||
|
||||
// Save discovered backups
|
||||
for (const backup of backups) {
|
||||
await db.createOrUpdateBackup(backup);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error discovering backups for script ${script.id} (CT ${script.container_id}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let backupServiceInstance: BackupService | null = null;
|
||||
|
||||
export function getBackupService(): BackupService {
|
||||
if (!backupServiceInstance) {
|
||||
backupServiceInstance = new BackupService();
|
||||
}
|
||||
return backupServiceInstance;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user