diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 065ec72..30708b1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/app/_components/BackupsTab.tsx b/src/app/_components/BackupsTab.tsx new file mode 100644 index 0000000..65e4972 --- /dev/null +++ b/src/app/_components/BackupsTab.tsx @@ -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>(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 ; + case 'local': + return ; + default: + return ; + } + }; + + 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 ( +
+ {/* Header with refresh button */} +
+
+

Backups

+

+ Discovered backups grouped by container ID +

+
+ +
+ + {/* Loading state */} + {(isLoading || isDiscovering) && backups.length === 0 && ( +
+ +

+ {isDiscovering ? 'Discovering backups...' : 'Loading backups...'} +

+
+ )} + + {/* Empty state */} + {!isLoading && !isDiscovering && backups.length === 0 && ( +
+ +

No backups found

+

+ Click "Discover Backups" to scan for backups on your servers. +

+ +
+ )} + + {/* Backups list */} + {!isLoading && backups.length > 0 && ( +
+ {backups.map((container: ContainerBackups) => { + const isExpanded = expandedContainers.has(container.container_id); + const backupCount = container.backups.length; + + return ( +
+ {/* Container header - collapsible */} + + + {/* Container content - backups list */} + {isExpanded && ( +
+
+ {container.backups.map((backup) => ( +
+
+
+
+ + {backup.backup_name} + + + {getStorageTypeIcon(backup.storage_type)} + {backup.storage_name} + +
+
+ {backup.size && ( + + + {formatFileSize(backup.size)} + + )} + {backup.created_at && ( + {formatDate(backup.created_at)} + )} + {backup.server_name && ( + + + {backup.server_name} + + )} +
+
+ + {backup.backup_path} + +
+
+
+
+ ))} +
+
+ )} +
+ ); + })} +
+ )} + + {/* Error state */} + {backupsData && !backupsData.success && ( +
+

+ Error loading backups: {backupsData.error || 'Unknown error'} +

+
+ )} +
+ ); +} + diff --git a/src/app/page.tsx b/src/app/page.tsx index 8e13279..98182a1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() { + @@ -273,6 +292,10 @@ export default function Home() { {activeTab === 'installed' && ( )} + + {activeTab === 'backups' && ( + + )} {/* Footer */} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 2b3a3fc..34767e0 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -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 diff --git a/src/server/api/routers/backups.ts b/src/server/api/routers/backups.ts new file mode 100644 index 0000000..78f0f09 --- /dev/null +++ b/src/server/api/routers/backups.ts @@ -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', + }; + } + }), +}); + diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index 927d638..7a2e24e 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -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((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); diff --git a/src/server/database-prisma.js b/src/server/database-prisma.js index dc86245..8fd760d 100644 --- a/src/server/database-prisma.js +++ b/src/server/database-prisma.js @@ -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(); } diff --git a/src/server/database-prisma.ts b/src/server/database-prisma.ts index d6d83d5..7fbcade 100644 --- a/src/server/database-prisma.ts +++ b/src/server/database-prisma.ts @@ -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>> { + 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(); } diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts new file mode 100644 index 0000000..6a41994 --- /dev/null +++ b/src/server/services/backupService.ts @@ -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 { + const sshService = getSSHExecutionService(); + let hostname = ''; + + await new Promise((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 { + 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((resolve) => { + sshService.executeCommand( + server, + findCommand, + (data: string) => { + findOutput += data; + }, + (error: string) => { + // Ignore errors - directory might not exist + resolve(); + }, + (exitCode: number) => { + resolve(); + } + ); + }), + new Promise((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((resolve) => { + sshService.executeCommand( + server, + statCommand, + (data: string) => { + statOutput += data; + }, + () => resolve(), + () => resolve() + ); + }), + new Promise((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//dump/) + */ + async discoverStorageBackups(server: Server, storage: Storage, ctId: string, hostname: string): Promise { + 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((resolve) => { + sshService.executeCommand( + server, + findCommand, + (data: string) => { + findOutput += data; + }, + (error: string) => { + // Ignore errors - storage might not be mounted + resolve(); + }, + (exitCode: number) => { + resolve(); + } + ); + }), + new Promise((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((resolve) => { + sshService.executeCommand( + server, + statCommand, + (data: string) => { + statOutput += data; + }, + () => resolve(), + () => resolve() + ); + }), + new Promise((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 { + 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((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((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 { + 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 { + 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; +} +