From 4ea49be97d0296098f1b5edf98a479a7407d1343 Mon Sep 17 00:00:00 2001
From: Michel Roegl-Brunner
Date: Fri, 14 Nov 2025 08:44:33 +0100
Subject: [PATCH 1/9] Initial for Backup function
---
server.js | 196 +++++++++++-
src/app/_components/BackupWarningModal.tsx | 65 ++++
src/app/_components/InstalledScriptsTab.tsx | 300 ++++++++++++++++--
.../_components/ScriptInstallationCard.tsx | 11 +
src/app/_components/ServerList.tsx | 31 +-
src/app/_components/ServerStoragesModal.tsx | 177 +++++++++++
src/app/_components/StorageSelectionModal.tsx | 166 ++++++++++
src/app/_components/Terminal.tsx | 10 +-
src/server/api/routers/installedScripts.ts | 110 +++++++
src/server/services/storageService.ts | 197 ++++++++++++
10 files changed, 1224 insertions(+), 39 deletions(-)
create mode 100644 src/app/_components/BackupWarningModal.tsx
create mode 100644 src/app/_components/ServerStoragesModal.tsx
create mode 100644 src/app/_components/StorageSelectionModal.tsx
create mode 100644 src/server/services/storageService.ts
diff --git a/server.js b/server.js
index 0ca86bf..6977a3e 100644
--- a/server.js
+++ b/server.js
@@ -276,13 +276,15 @@ class ScriptExecutionHandler {
* @param {WebSocketMessage} message
*/
async handleMessage(ws, message) {
- const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
+ const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message;
switch (action) {
case 'start':
if (scriptPath && executionId) {
- if (isUpdate && containerId) {
- await this.startUpdateExecution(ws, containerId, executionId, mode, server);
+ if (isBackup && containerId && storage) {
+ await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
+ } else if (isUpdate && containerId) {
+ await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
} else if (isShell && containerId) {
await this.startShellExecution(ws, containerId, executionId, mode, server);
} else {
@@ -660,6 +662,115 @@ class ScriptExecutionHandler {
}
}
+ /**
+ * Start backup execution
+ * @param {ExtendedWebSocket} ws
+ * @param {string} containerId
+ * @param {string} executionId
+ * @param {string} storage
+ * @param {string} mode
+ * @param {ServerInfo|null} server
+ */
+ async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) {
+ try {
+ // Send start message
+ this.sendMessage(ws, {
+ type: 'start',
+ data: `Starting backup for container ${containerId} to storage ${storage}...`,
+ timestamp: Date.now()
+ });
+
+ if (mode === 'ssh' && server) {
+ await this.startSSHBackupExecution(ws, containerId, executionId, storage, server);
+ } else {
+ this.sendMessage(ws, {
+ type: 'error',
+ data: 'Backup is only supported via SSH',
+ timestamp: Date.now()
+ });
+ }
+ } catch (error) {
+ this.sendMessage(ws, {
+ type: 'error',
+ data: `Failed to start backup: ${error instanceof Error ? error.message : String(error)}`,
+ timestamp: Date.now()
+ });
+ }
+ }
+
+ /**
+ * Start SSH backup execution
+ * @param {ExtendedWebSocket} ws
+ * @param {string} containerId
+ * @param {string} executionId
+ * @param {string} storage
+ * @param {ServerInfo} server
+ */
+ async startSSHBackupExecution(ws, containerId, executionId, storage, server) {
+ const sshService = getSSHExecutionService();
+
+ try {
+ const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
+
+ const execution = await sshService.executeCommand(
+ server,
+ backupCommand,
+ /** @param {string} data */
+ (data) => {
+ this.sendMessage(ws, {
+ type: 'output',
+ data: data,
+ timestamp: Date.now()
+ });
+ },
+ /** @param {string} error */
+ (error) => {
+ this.sendMessage(ws, {
+ type: 'error',
+ data: error,
+ timestamp: Date.now()
+ });
+ },
+ /** @param {number} code */
+ (code) => {
+ if (code === 0) {
+ this.sendMessage(ws, {
+ type: 'end',
+ data: `Backup completed successfully with exit code: ${code}`,
+ timestamp: Date.now()
+ });
+ } else {
+ this.sendMessage(ws, {
+ type: 'error',
+ data: `Backup failed with exit code: ${code}`,
+ timestamp: Date.now()
+ });
+ this.sendMessage(ws, {
+ type: 'end',
+ data: `Backup execution ended with exit code: ${code}`,
+ timestamp: Date.now()
+ });
+ }
+
+ this.activeExecutions.delete(executionId);
+ }
+ );
+
+ // Store the execution
+ this.activeExecutions.set(executionId, {
+ process: /** @type {any} */ (execution).process,
+ ws
+ });
+
+ } catch (error) {
+ this.sendMessage(ws, {
+ type: 'error',
+ data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
+ timestamp: Date.now()
+ });
+ }
+ }
+
/**
* Start update execution (pct enter + update command)
* @param {ExtendedWebSocket} ws
@@ -667,11 +778,86 @@ class ScriptExecutionHandler {
* @param {string} executionId
* @param {string} mode
* @param {ServerInfo|null} server
+ * @param {string} [backupStorage] - Optional storage to backup to before update
*/
- async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null) {
+ async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
try {
+ // If backup storage is provided, run backup first
+ if (backupStorage && mode === 'ssh' && server) {
+ this.sendMessage(ws, {
+ type: 'start',
+ data: `Starting backup before update for container ${containerId}...`,
+ timestamp: Date.now()
+ });
+
+ // Create a separate execution ID for backup
+ const backupExecutionId = `backup_${executionId}`;
+ let backupCompleted = false;
+ let backupSucceeded = false;
+
+ // Run backup and wait for it to complete
+ await new Promise((resolve) => {
+ // Create a wrapper websocket that forwards messages and tracks completion
+ const backupWs = {
+ send: (data) => {
+ try {
+ const message = typeof data === 'string' ? JSON.parse(data) : data;
+
+ // Forward all messages to the main websocket
+ ws.send(JSON.stringify(message));
+
+ // Check for completion
+ if (message.type === 'end') {
+ backupCompleted = true;
+ backupSucceeded = !message.data.includes('failed') && !message.data.includes('exit code:');
+ if (!backupSucceeded) {
+ // Backup failed, but we'll still allow update (per requirement 1b)
+ ws.send(JSON.stringify({
+ type: 'output',
+ data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
+ timestamp: Date.now()
+ }));
+ }
+ resolve();
+ } else if (message.type === 'error' && message.data.includes('Backup failed')) {
+ backupCompleted = true;
+ backupSucceeded = false;
+ ws.send(JSON.stringify({
+ type: 'output',
+ data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
+ timestamp: Date.now()
+ }));
+ resolve();
+ }
+ } catch (e) {
+ // If parsing fails, just forward the raw data
+ ws.send(data);
+ }
+ }
+ };
+
+ // Start backup execution
+ this.startSSHBackupExecution(backupWs, containerId, backupExecutionId, backupStorage, server)
+ .catch((error) => {
+ // Backup failed to start, but allow update to proceed
+ if (!backupCompleted) {
+ backupCompleted = true;
+ backupSucceeded = false;
+ ws.send(JSON.stringify({
+ type: 'output',
+ data: `\n⚠️ Backup error: ${error.message}. Proceeding with update...\n`,
+ timestamp: Date.now()
+ }));
+ resolve();
+ }
+ });
+ });
+
+ // Small delay before starting update
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
- // Send start message
+ // Send start message for update
this.sendMessage(ws, {
type: 'start',
data: `Starting update for container ${containerId}...`,
diff --git a/src/app/_components/BackupWarningModal.tsx b/src/app/_components/BackupWarningModal.tsx
new file mode 100644
index 0000000..c7b5fb0
--- /dev/null
+++ b/src/app/_components/BackupWarningModal.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import { Button } from './ui/button';
+import { AlertTriangle } from 'lucide-react';
+import { useRegisterModal } from './modal/ModalStackProvider';
+
+interface BackupWarningModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onProceed: () => void;
+}
+
+export function BackupWarningModal({
+ isOpen,
+ onClose,
+ onProceed
+}: BackupWarningModalProps) {
+ useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* Header */}
+
+
+ {/* Content */}
+
+
+ The backup failed, but you can still proceed with the update if you wish.
+
+ Warning: Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update.
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx
index 02155a4..a634598 100644
--- a/src/app/_components/InstalledScriptsTab.tsx
+++ b/src/app/_components/InstalledScriptsTab.tsx
@@ -10,6 +10,9 @@ import { ConfirmationModal } from './ConfirmationModal';
import { ErrorModal } from './ErrorModal';
import { LoadingModal } from './LoadingModal';
import { LXCSettingsModal } from './LXCSettingsModal';
+import { StorageSelectionModal } from './StorageSelectionModal';
+import { BackupWarningModal } from './BackupWarningModal';
+import type { Storage } from '~/server/services/storageService';
import { getContrastColor } from '../../lib/colorUtils';
import {
DropdownMenu,
@@ -50,8 +53,14 @@ export function InstalledScriptsTab() {
const [serverFilter, setServerFilter] = useState('all');
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
- const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
+ const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; backupStorage?: string; isBackupOnly?: boolean } | null>(null);
const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null);
+ const [showBackupPrompt, setShowBackupPrompt] = useState(false);
+ const [showStorageSelection, setShowStorageSelection] = useState(false);
+ const [pendingUpdateScript, setPendingUpdateScript] = useState(null);
+ const [backupStorages, setBackupStorages] = useState([]);
+ const [isLoadingStorages, setIsLoadingStorages] = useState(false);
+ const [showBackupWarning, setShowBackupWarning] = useState(false);
const [editingScriptId, setEditingScriptId] = useState(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
const [showAddForm, setShowAddForm] = useState(false);
@@ -244,22 +253,54 @@ export function InstalledScriptsTab() {
void refetchScripts();
setAutoDetectStatus({
type: 'success',
- message: data.message ?? 'Web UI IP detected successfully!'
+ message: data.success ? `Detected IP: ${data.ip}` : (data.error ?? 'Failed to detect Web UI')
});
- // Clear status after 5 seconds
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
},
onError: (error) => {
- console.error('❌ Auto-detect Web UI error:', error);
+ console.error('❌ Auto-detect WebUI error:', error);
setAutoDetectStatus({
type: 'error',
- message: error.message ?? 'Auto-detect failed. Please try again.'
+ message: error.message ?? 'Failed to detect Web UI'
});
- // Clear status after 5 seconds
- setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
+ setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000);
}
});
+ // Get backup storages query
+ const getBackupStoragesQuery = api.installedScripts.getBackupStorages.useQuery(
+ { serverId: pendingUpdateScript?.server_id ?? 0, forceRefresh: false },
+ { enabled: false } // Only fetch when explicitly called
+ );
+
+ const fetchStorages = async (serverId: number, forceRefresh = false) => {
+ setIsLoadingStorages(true);
+ try {
+ const result = await getBackupStoragesQuery.refetch({
+ queryKey: ['installedScripts.getBackupStorages', { serverId, forceRefresh }]
+ });
+ if (result.data?.success) {
+ setBackupStorages(result.data.storages);
+ } else {
+ setErrorModal({
+ isOpen: true,
+ title: 'Failed to Fetch Storages',
+ message: result.data?.error ?? 'Unknown error occurred',
+ type: 'error'
+ });
+ }
+ } catch (error) {
+ setErrorModal({
+ isOpen: true,
+ title: 'Failed to Fetch Storages',
+ message: error instanceof Error ? error.message : 'Unknown error occurred',
+ type: 'error'
+ });
+ } finally {
+ setIsLoadingStorages(false);
+ }
+ };
+
// Container control mutations
// Note: getStatusMutation removed - using direct API calls instead
@@ -600,38 +641,149 @@ export function InstalledScriptsTab() {
message: `Are you sure you want to update "${script.script_name}"?\n\n⚠️ WARNING: This will update the script and may affect the container. Consider backing up your data beforehand.`,
variant: 'danger',
confirmText: script.container_id,
- confirmButtonText: 'Update Script',
+ confirmButtonText: 'Continue',
onConfirm: () => {
- // Get server info if it's SSH mode
- let server = null;
- if (script.server_id && script.server_user) {
- server = {
- id: script.server_id,
- name: script.server_name,
- ip: script.server_ip,
- user: script.server_user,
- password: script.server_password,
- auth_type: script.server_auth_type ?? 'password',
- ssh_key: script.server_ssh_key,
- ssh_key_passphrase: script.server_ssh_key_passphrase,
- ssh_port: script.server_ssh_port ?? 22
- };
- }
-
- setUpdatingScript({
- id: script.id,
- containerId: script.container_id!,
- server: server
- });
setConfirmationModal(null);
+ // Store the script for backup flow
+ setPendingUpdateScript(script);
+ // Show backup prompt
+ setShowBackupPrompt(true);
}
});
};
+ const handleBackupPromptResponse = (wantsBackup: boolean) => {
+ setShowBackupPrompt(false);
+
+ if (!pendingUpdateScript) return;
+
+ if (wantsBackup) {
+ // User wants backup - fetch storages and show selection
+ if (pendingUpdateScript.server_id) {
+ void fetchStorages(pendingUpdateScript.server_id, false);
+ setShowStorageSelection(true);
+ } else {
+ setErrorModal({
+ isOpen: true,
+ title: 'Backup Not Available',
+ message: 'Backup is only available for SSH scripts with a configured server.',
+ type: 'error'
+ });
+ // Proceed without backup
+ proceedWithUpdate(null);
+ }
+ } else {
+ // User doesn't want backup - proceed directly to update
+ proceedWithUpdate(null);
+ }
+ };
+
+ const handleStorageSelected = (storage: Storage) => {
+ setShowStorageSelection(false);
+
+ // Check if this is for a standalone backup or pre-update backup
+ if (pendingUpdateScript && !showBackupPrompt) {
+ // Standalone backup - execute backup directly
+ executeStandaloneBackup(pendingUpdateScript, storage.name);
+ } else {
+ // Pre-update backup - proceed with update
+ proceedWithUpdate(storage.name);
+ }
+ };
+
+ const executeStandaloneBackup = (script: InstalledScript, storageName: string) => {
+ // Get server info
+ let server = null;
+ if (script.server_id && script.server_user) {
+ server = {
+ id: script.server_id,
+ name: script.server_name,
+ ip: script.server_ip,
+ user: script.server_user,
+ password: script.server_password,
+ auth_type: script.server_auth_type ?? 'password',
+ ssh_key: script.server_ssh_key,
+ ssh_key_passphrase: script.server_ssh_key_passphrase,
+ ssh_port: script.server_ssh_port ?? 22
+ };
+ }
+
+ // Start backup terminal
+ setUpdatingScript({
+ id: script.id,
+ containerId: script.container_id!,
+ server: server,
+ backupStorage: storageName,
+ isBackupOnly: true
+ });
+
+ // Reset state
+ setPendingUpdateScript(null);
+ setBackupStorages([]);
+ };
+
+ const proceedWithUpdate = (backupStorage: string | null) => {
+ if (!pendingUpdateScript) return;
+
+ // Get server info if it's SSH mode
+ let server = null;
+ if (pendingUpdateScript.server_id && pendingUpdateScript.server_user) {
+ server = {
+ id: pendingUpdateScript.server_id,
+ name: pendingUpdateScript.server_name,
+ ip: pendingUpdateScript.server_ip,
+ user: pendingUpdateScript.server_user,
+ password: pendingUpdateScript.server_password,
+ auth_type: pendingUpdateScript.server_auth_type ?? 'password',
+ ssh_key: pendingUpdateScript.server_ssh_key,
+ ssh_key_passphrase: pendingUpdateScript.server_ssh_key_passphrase,
+ ssh_port: pendingUpdateScript.server_ssh_port ?? 22
+ };
+ }
+
+ setUpdatingScript({
+ id: pendingUpdateScript.id,
+ containerId: pendingUpdateScript.container_id!,
+ server: server,
+ backupStorage: backupStorage ?? undefined
+ });
+
+ // Reset state
+ setPendingUpdateScript(null);
+ setBackupStorages([]);
+ };
+
const handleCloseUpdateTerminal = () => {
setUpdatingScript(null);
};
+ const handleBackupScript = (script: InstalledScript) => {
+ if (!script.container_id) {
+ setErrorModal({
+ isOpen: true,
+ title: 'Backup Failed',
+ message: 'No Container ID available for this script',
+ details: 'This script does not have a valid container ID and cannot be backed up.'
+ });
+ return;
+ }
+
+ if (!script.server_id) {
+ setErrorModal({
+ isOpen: true,
+ title: 'Backup Not Available',
+ message: 'Backup is only available for SSH scripts with a configured server.',
+ type: 'error'
+ });
+ return;
+ }
+
+ // Store the script and fetch storages
+ setPendingUpdateScript(script);
+ void fetchStorages(script.server_id, false);
+ setShowStorageSelection(true);
+ };
+
const handleOpenShell = (script: InstalledScript) => {
if (!script.container_id) {
setErrorModal({
@@ -887,12 +1039,15 @@ export function InstalledScriptsTab() {
{updatingScript && (
)}
@@ -1252,6 +1407,7 @@ export function InstalledScriptsTab() {
onSave={handleSaveEdit}
onCancel={handleCancelEdit}
onUpdate={() => handleUpdateScript(script)}
+ onBackup={() => handleBackupScript(script)}
onShell={() => handleOpenShell(script)}
onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending}
@@ -1530,6 +1686,15 @@ export function InstalledScriptsTab() {
Update
)}
+ {script.container_id && script.execution_mode === 'ssh' && (
+ handleBackupScript(script)}
+ disabled={containerStatuses.get(script.id) === 'stopped'}
+ className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
+ >
+ Backup
+
+ )}
{script.container_id && script.execution_mode === 'ssh' && (
handleOpenShell(script)}
@@ -1656,6 +1821,79 @@ export function InstalledScriptsTab() {
/>
)}
+ {/* Backup Prompt Modal */}
+ {showBackupPrompt && (
+
+
+
+
+
+
Backup Before Update?
+
+
+
+
+ Would you like to create a backup before updating the container?
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Storage Selection Modal */}
+ {
+ setShowStorageSelection(false);
+ setPendingUpdateScript(null);
+ setBackupStorages([]);
+ }}
+ onSelect={handleStorageSelected}
+ storages={backupStorages}
+ isLoading={isLoadingStorages}
+ onRefresh={() => {
+ if (pendingUpdateScript?.server_id) {
+ void fetchStorages(pendingUpdateScript.server_id, true);
+ }
+ }}
+ />
+
+ {/* Backup Warning Modal */}
+ setShowBackupWarning(false)}
+ onProceed={() => {
+ setShowBackupWarning(false);
+ // Proceed with update even though backup failed
+ if (pendingUpdateScript) {
+ proceedWithUpdate(null);
+ }
+ }}
+ />
+
{/* LXC Settings Modal */}
void;
onCancel: () => void;
onUpdate: () => void;
+ onBackup?: () => void;
onShell: () => void;
onDelete: () => void;
isUpdating: boolean;
@@ -68,6 +69,7 @@ export function ScriptInstallationCard({
onSave,
onCancel,
onUpdate,
+ onBackup,
onShell,
onDelete,
isUpdating,
@@ -307,6 +309,15 @@ export function ScriptInstallationCard({
Update
)}
+ {script.container_id && script.execution_mode === 'ssh' && onBackup && (
+
+ Backup
+
+ )}
{script.container_id && script.execution_mode === 'ssh' && (
(null);
+ const [showStoragesModal, setShowStoragesModal] = useState(false);
+ const [selectedServerForStorages, setSelectedServerForStorages] = useState<{ id: number; name: string } | null>(null);
const handleEdit = (server: Server) => {
setEditingId(server.id);
@@ -251,6 +254,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
>
)}
+
{/* View Public Key button - only show for generated keys */}
{server.key_generated === true && (
@@ -324,6 +340,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
serverIp={publicKeyData.serverIp}
/>
)}
+
+ {/* Server Storages Modal */}
+ {selectedServerForStorages && (
+ {
+ setShowStoragesModal(false);
+ setSelectedServerForStorages(null);
+ }}
+ serverId={selectedServerForStorages.id}
+ serverName={selectedServerForStorages.name}
+ />
+ )}
);
}
diff --git a/src/app/_components/ServerStoragesModal.tsx b/src/app/_components/ServerStoragesModal.tsx
new file mode 100644
index 0000000..ffc9316
--- /dev/null
+++ b/src/app/_components/ServerStoragesModal.tsx
@@ -0,0 +1,177 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Button } from './ui/button';
+import { Database, RefreshCw, CheckCircle } from 'lucide-react';
+import { useRegisterModal } from './modal/ModalStackProvider';
+import { api } from '~/trpc/react';
+import type { Storage } from '~/server/services/storageService';
+
+interface ServerStoragesModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ serverId: number;
+ serverName: string;
+}
+
+export function ServerStoragesModal({
+ isOpen,
+ onClose,
+ serverId,
+ serverName
+}: ServerStoragesModalProps) {
+ const [forceRefresh, setForceRefresh] = useState(false);
+
+ const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
+ { serverId, forceRefresh },
+ { enabled: isOpen }
+ );
+
+ useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
+
+ const handleRefresh = () => {
+ setForceRefresh(true);
+ void refetch();
+ setTimeout(() => setForceRefresh(false), 1000);
+ };
+
+ if (!isOpen) return null;
+
+ const storages = data?.success ? data.storages : [];
+ const backupStorages = storages.filter(s => s.supportsBackup);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Storages for {serverName}
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {isLoading ? (
+
+
+
Loading storages...
+
+ ) : !data?.success ? (
+
+
+
Failed to load storages
+
+ {data?.error ?? 'Unknown error occurred'}
+
+
+
+ ) : storages.length === 0 ? (
+
+
+
No storages found
+
+ Make sure your server has storages configured.
+
+
+ ) : (
+ <>
+ {data.cached && (
+
+ Showing cached data. Click Refresh to fetch latest from server.
+
+ )}
+
+
+ {storages.map((storage) => {
+ const isBackupCapable = storage.supportsBackup;
+
+ return (
+
+
+
+
+
{storage.name}
+ {isBackupCapable && (
+
+
+ Backup
+
+ )}
+
+ {storage.type}
+
+
+
+
+ Content: {storage.content.join(', ')}
+
+ {storage.nodes && storage.nodes.length > 0 && (
+
+ Nodes: {storage.nodes.join(', ')}
+
+ )}
+ {Object.entries(storage)
+ .filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
+ .map(([key, value]) => (
+
+ {key.replace(/_/g, ' ')}: {String(value)}
+
+ ))}
+
+
+
+
+ );
+ })}
+
+
+ {backupStorages.length > 0 && (
+
+
+ {backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
+
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
+
diff --git a/src/app/_components/StorageSelectionModal.tsx b/src/app/_components/StorageSelectionModal.tsx
new file mode 100644
index 0000000..ee998ce
--- /dev/null
+++ b/src/app/_components/StorageSelectionModal.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from './ui/button';
+import { Database, RefreshCw, CheckCircle } from 'lucide-react';
+import { useRegisterModal } from './modal/ModalStackProvider';
+import type { Storage } from '~/server/services/storageService';
+
+interface StorageSelectionModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSelect: (storage: Storage) => void;
+ storages: Storage[];
+ isLoading: boolean;
+ onRefresh: () => void;
+}
+
+export function StorageSelectionModal({
+ isOpen,
+ onClose,
+ onSelect,
+ storages,
+ isLoading,
+ onRefresh
+}: StorageSelectionModalProps) {
+ const [selectedStorage, setSelectedStorage] = useState(null);
+
+ useRegisterModal(isOpen, { id: 'storage-selection-modal', allowEscape: true, onClose });
+
+ if (!isOpen) return null;
+
+ const handleSelect = () => {
+ if (selectedStorage) {
+ onSelect(selectedStorage);
+ setSelectedStorage(null);
+ }
+ };
+
+ const handleClose = () => {
+ setSelectedStorage(null);
+ onClose();
+ };
+
+ // Filter to show only backup-capable storages
+ const backupStorages = storages.filter(s => s.supportsBackup);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
Select Backup Storage
+
+
+
+
+ {/* Content */}
+
+ {isLoading ? (
+
+
+
Loading storages...
+
+ ) : backupStorages.length === 0 ? (
+
+
+
No backup-capable storages found
+
+ Make sure your server has storages configured with backup content type.
+
+
+
+ ) : (
+ <>
+
+ Select a storage to use for the backup. Only storages that support backups are shown.
+
+
+ {/* Storage List */}
+
+ {backupStorages.map((storage) => (
+
setSelectedStorage(storage)}
+ className={`p-4 border rounded-lg cursor-pointer transition-all ${
+ selectedStorage?.name === storage.name
+ ? 'border-primary bg-primary/10'
+ : 'border-border hover:border-primary/50 hover:bg-accent/50'
+ }`}
+ >
+
+
+
+
{storage.name}
+
+ Backup
+
+
+ {storage.type}
+
+
+
+ Content: {storage.content.join(', ')}
+ {storage.nodes && storage.nodes.length > 0 && (
+ • Nodes: {storage.nodes.join(', ')}
+ )}
+
+
+ {selectedStorage?.name === storage.name && (
+
+ )}
+
+
+ ))}
+
+
+ {/* Refresh Button */}
+
+
+
+ >
+ )}
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx
index b18e108..84c0ec1 100644
--- a/src/app/_components/Terminal.tsx
+++ b/src/app/_components/Terminal.tsx
@@ -12,7 +12,10 @@ interface TerminalProps {
server?: any;
isUpdate?: boolean;
isShell?: boolean;
+ isBackup?: boolean;
containerId?: string;
+ storage?: string;
+ backupStorage?: string;
}
interface TerminalMessage {
@@ -21,7 +24,7 @@ interface TerminalMessage {
timestamp: number;
}
-export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
+export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) {
const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false);
@@ -334,7 +337,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
server,
isUpdate,
isShell,
- containerId
+ isBackup,
+ containerId,
+ storage,
+ backupStorage
};
ws.send(JSON.stringify(message));
}
diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts
index 5bce857..e0273d9 100644
--- a/src/server/api/routers/installedScripts.ts
+++ b/src/server/api/routers/installedScripts.ts
@@ -3,6 +3,8 @@ import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database-prisma";
import { createHash } from "crypto";
import type { Server } from "~/types/server";
+import { getStorageService } from "~/server/services/storageService";
+import { SSHService } from "~/server/ssh-service";
// Helper function to parse raw LXC config into structured data
function parseRawConfig(rawConfig: string): any {
@@ -2038,5 +2040,113 @@ EOFCONFIG`;
.getLXCConfig({ scriptId: input.scriptId, forceSync: true });
return result;
+ }),
+
+ // Get backup-capable storages for a server
+ getBackupStorages: publicProcedure
+ .input(z.object({
+ serverId: z.number(),
+ forceRefresh: z.boolean().optional().default(false)
+ }))
+ .query(async ({ input }) => {
+ try {
+ const db = getDatabase();
+ const server = await db.getServerById(input.serverId);
+
+ if (!server) {
+ return {
+ success: false,
+ error: 'Server not found',
+ storages: [],
+ cached: false
+ };
+ }
+
+ const storageService = getStorageService();
+ const sshService = new SSHService();
+
+ // Test SSH connection first
+ const connectionTest = await sshService.testSSHConnection(server as Server);
+ if (!(connectionTest as any).success) {
+ return {
+ success: false,
+ error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
+ storages: [],
+ cached: false
+ };
+ }
+
+ // 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);
+
+ return {
+ success: true,
+ storages: allStorages,
+ cached: wasCached && allStorages.length > 0
+ };
+ } catch (error) {
+ console.error('Error in getBackupStorages:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch storages',
+ storages: [],
+ cached: false
+ };
+ }
+ }),
+
+ // Execute backup for a container
+ executeBackup: publicProcedure
+ .input(z.object({
+ containerId: z.string(),
+ storage: z.string(),
+ serverId: z.number()
+ }))
+ .mutation(async ({ input }) => {
+ try {
+ const db = getDatabase();
+ const server = await db.getServerById(input.serverId);
+
+ if (!server) {
+ return {
+ success: false,
+ error: 'Server not found',
+ executionId: null
+ };
+ }
+
+ const sshService = new SSHService();
+
+ // Test SSH connection first
+ const connectionTest = await sshService.testSSHConnection(server as Server);
+ if (!(connectionTest as any).success) {
+ return {
+ success: false,
+ error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
+ executionId: null
+ };
+ }
+
+ // Generate execution ID for websocket tracking
+ const executionId = `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+
+ return {
+ success: true,
+ executionId,
+ containerId: input.containerId,
+ storage: input.storage,
+ server: server as Server
+ };
+ } catch (error) {
+ console.error('Error in executeBackup:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to execute backup',
+ executionId: null
+ };
+ }
})
});
diff --git a/src/server/services/storageService.ts b/src/server/services/storageService.ts
new file mode 100644
index 0000000..c1c3bfb
--- /dev/null
+++ b/src/server/services/storageService.ts
@@ -0,0 +1,197 @@
+import { getSSHExecutionService } from '../ssh-execution-service';
+import type { Server } from '~/types/server';
+
+export interface Storage {
+ name: string;
+ type: string;
+ content: string[];
+ supportsBackup: boolean;
+ nodes?: string[];
+ [key: string]: any; // For additional storage-specific properties
+}
+
+interface CachedStorageData {
+ storages: Storage[];
+ lastFetched: Date;
+}
+
+class StorageService {
+ private cache: Map = new Map();
+ private readonly CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
+
+ /**
+ * Parse storage.cfg content and extract storage information
+ */
+ private parseStorageConfig(configContent: string): Storage[] {
+ const storages: Storage[] = [];
+ const lines = configContent.split('\n');
+
+ let currentStorage: Partial | null = null;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+
+ // Skip empty lines and comments
+ if (!line || line.startsWith('#')) {
+ continue;
+ }
+
+ // Check if this is a storage definition line (format: "type: name")
+ const storageMatch = line.match(/^(\w+):\s*(.+)$/);
+ if (storageMatch) {
+ // Save previous storage if exists
+ if (currentStorage && currentStorage.name) {
+ storages.push(this.finalizeStorage(currentStorage));
+ }
+
+ // Start new storage
+ currentStorage = {
+ type: storageMatch[1],
+ name: storageMatch[2],
+ content: [],
+ supportsBackup: false,
+ };
+ continue;
+ }
+
+ // Parse storage properties (indented lines)
+ if (currentStorage && /^\s/.test(line)) {
+ const propertyMatch = line.match(/^\s+(\w+)\s+(.+)$/);
+ if (propertyMatch) {
+ const key = propertyMatch[1];
+ const value = propertyMatch[2];
+
+ switch (key) {
+ case 'content':
+ // Content can be comma-separated: "images,rootdir" or "backup"
+ currentStorage.content = value.split(',').map(c => c.trim());
+ currentStorage.supportsBackup = currentStorage.content.includes('backup');
+ break;
+ case 'nodes':
+ // Nodes can be comma-separated: "prox5" or "prox5,prox6"
+ currentStorage.nodes = value.split(',').map(n => n.trim());
+ break;
+ default:
+ // Store other properties
+ (currentStorage as any)[key] = value;
+ }
+ }
+ }
+ }
+
+ // Don't forget the last storage
+ if (currentStorage && currentStorage.name) {
+ storages.push(this.finalizeStorage(currentStorage));
+ }
+
+ return storages;
+ }
+
+ /**
+ * Finalize storage object with proper typing
+ */
+ private finalizeStorage(storage: Partial): Storage {
+ return {
+ name: storage.name!,
+ type: storage.type!,
+ content: storage.content || [],
+ supportsBackup: storage.supportsBackup || false,
+ nodes: storage.nodes,
+ ...Object.fromEntries(
+ Object.entries(storage).filter(([key]) =>
+ !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key)
+ )
+ ),
+ };
+ }
+
+ /**
+ * Fetch storage configuration from server via SSH
+ */
+ async fetchStoragesFromServer(server: Server, forceRefresh = false): Promise {
+ const serverId = server.id;
+
+ // Check cache first (unless force refresh)
+ if (!forceRefresh && this.cache.has(serverId)) {
+ const cached = this.cache.get(serverId)!;
+ const age = Date.now() - cached.lastFetched.getTime();
+
+ if (age < this.CACHE_TTL_MS) {
+ return cached.storages;
+ }
+ }
+
+ // Fetch from server
+ const sshService = getSSHExecutionService();
+ let configContent = '';
+
+ await new Promise((resolve, reject) => {
+ sshService.executeCommand(
+ server,
+ 'cat /etc/pve/storage.cfg',
+ (data: string) => {
+ configContent += data;
+ },
+ (error: string) => {
+ reject(new Error(`Failed to read storage config: ${error}`));
+ },
+ (exitCode: number) => {
+ if (exitCode === 0) {
+ resolve();
+ } else {
+ reject(new Error(`Command failed with exit code ${exitCode}`));
+ }
+ }
+ );
+ });
+
+ // Parse and cache
+ const storages = this.parseStorageConfig(configContent);
+ this.cache.set(serverId, {
+ storages,
+ lastFetched: new Date(),
+ });
+
+ return storages;
+ }
+
+ /**
+ * Get all storages for a server (cached or fresh)
+ */
+ async getStorages(server: Server, forceRefresh = false): Promise {
+ return this.fetchStoragesFromServer(server, forceRefresh);
+ }
+
+ /**
+ * Get only backup-capable storages
+ */
+ async getBackupStorages(server: Server, forceRefresh = false): Promise {
+ const allStorages = await this.getStorages(server, forceRefresh);
+ return allStorages.filter(s => s.supportsBackup);
+ }
+
+ /**
+ * Clear cache for a specific server
+ */
+ clearCache(serverId: number): void {
+ this.cache.delete(serverId);
+ }
+
+ /**
+ * Clear all caches
+ */
+ clearAllCaches(): void {
+ this.cache.clear();
+ }
+}
+
+// Singleton instance
+let storageServiceInstance: StorageService | null = null;
+
+export function getStorageService(): StorageService {
+ if (!storageServiceInstance) {
+ storageServiceInstance = new StorageService();
+ }
+ return storageServiceInstance;
+}
+
From d50ea55e6d90ecd5f85f1402dd1852076fcc7100 Mon Sep 17 00:00:00 2001
From: Michel Roegl-Brunner
Date: Fri, 14 Nov 2025 10:30:27 +0100
Subject: [PATCH 2/9] Add LXC container backup functionality
- Add backup capability before updates or as standalone action
- Implement storage service to fetch and parse backup-capable storages from PVE nodes
- Add backup storage selection modal for user choice
- Support backup+update flow with sequential execution
- Add standalone backup option in Actions menu
- Add storage viewer in server section to show available storages
- Parse /etc/pve/storage.cfg to identify backup-capable storages
- Cache storage data for performance
- Handle backup failures gracefully (warn but allow update to proceed)
---
server.js | 234 +++++++++++---------
src/app/_components/InstalledScriptsTab.tsx | 16 +-
src/server/api/routers/installedScripts.ts | 3 +-
src/server/services/storageService.ts | 56 +++--
4 files changed, 173 insertions(+), 136 deletions(-)
diff --git a/server.js b/server.js
index 6977a3e..9cf6d8a 100644
--- a/server.js
+++ b/server.js
@@ -705,70 +705,112 @@ class ScriptExecutionHandler {
* @param {string} executionId
* @param {string} storage
* @param {ServerInfo} server
+ * @param {Function} [onComplete] - Optional callback when backup completes
*/
- async startSSHBackupExecution(ws, containerId, executionId, storage, server) {
+ startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
const sshService = getSSHExecutionService();
- try {
- const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
-
- const execution = await sshService.executeCommand(
- server,
- backupCommand,
- /** @param {string} data */
- (data) => {
- this.sendMessage(ws, {
- type: 'output',
- data: data,
- timestamp: Date.now()
- });
- },
- /** @param {string} error */
- (error) => {
- this.sendMessage(ws, {
- type: 'error',
- data: error,
- timestamp: Date.now()
- });
- },
- /** @param {number} code */
- (code) => {
- if (code === 0) {
+ return new Promise((resolve, reject) => {
+ try {
+ const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
+
+ // Wrap the onExit callback to resolve our promise
+ let promiseResolved = false;
+
+ sshService.executeCommand(
+ server,
+ backupCommand,
+ /** @param {string} data */
+ (data) => {
this.sendMessage(ws, {
- type: 'end',
- data: `Backup completed successfully with exit code: ${code}`,
+ type: 'output',
+ data: data,
timestamp: Date.now()
});
- } else {
+ },
+ /** @param {string} error */
+ (error) => {
this.sendMessage(ws, {
type: 'error',
- data: `Backup failed with exit code: ${code}`,
+ data: error,
timestamp: Date.now()
});
+ },
+ /** @param {number} code */
+ (code) => {
+ // Don't send 'end' message here if this is part of a backup+update flow
+ // The update flow will handle completion messages
+ const success = code === 0;
+
+ if (!success) {
+ this.sendMessage(ws, {
+ type: 'error',
+ data: `Backup failed with exit code: ${code}`,
+ timestamp: Date.now()
+ });
+ }
+
+ // Send a completion message (but not 'end' type to avoid stopping terminal)
this.sendMessage(ws, {
- type: 'end',
- data: `Backup execution ended with exit code: ${code}`,
+ type: 'output',
+ data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
timestamp: Date.now()
});
+
+ if (onComplete) onComplete(success);
+
+ // Resolve the promise when backup completes
+ // Use setImmediate to ensure resolution happens in the right execution context
+ if (!promiseResolved) {
+ promiseResolved = true;
+ const result = { success, code };
+
+ // Use setImmediate to ensure promise resolution happens in the next tick
+ // This ensures the await in startUpdateExecution can properly resume
+ setImmediate(() => {
+ try {
+ resolve(result);
+ } catch (resolveError) {
+ console.error('Error resolving backup promise:', resolveError);
+ reject(resolveError);
+ }
+ });
+ }
+
+ this.activeExecutions.delete(executionId);
}
-
- this.activeExecutions.delete(executionId);
- }
- );
+ ).then((execution) => {
+ // Store the execution
+ this.activeExecutions.set(executionId, {
+ process: /** @type {any} */ (execution).process,
+ ws
+ });
+ // Note: Don't resolve here - wait for onExit callback
+ }).catch((error) => {
+ console.error('Error starting backup execution:', error);
+ this.sendMessage(ws, {
+ type: 'error',
+ data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
+ timestamp: Date.now()
+ });
+ if (onComplete) onComplete(false);
+ if (!promiseResolved) {
+ promiseResolved = true;
+ reject(error);
+ }
+ });
- // Store the execution
- this.activeExecutions.set(executionId, {
- process: /** @type {any} */ (execution).process,
- ws
- });
-
- } catch (error) {
- this.sendMessage(ws, {
- type: 'error',
- data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
- timestamp: Date.now()
- });
- }
+ } catch (error) {
+ console.error('Error in startSSHBackupExecution:', error);
+ this.sendMessage(ws, {
+ type: 'error',
+ data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
+ timestamp: Date.now()
+ });
+ if (onComplete) onComplete(false);
+ reject(error);
+ }
+ });
}
/**
@@ -792,72 +834,48 @@ class ScriptExecutionHandler {
// Create a separate execution ID for backup
const backupExecutionId = `backup_${executionId}`;
- let backupCompleted = false;
- let backupSucceeded = false;
// Run backup and wait for it to complete
- await new Promise((resolve) => {
- // Create a wrapper websocket that forwards messages and tracks completion
- const backupWs = {
- send: (data) => {
- try {
- const message = typeof data === 'string' ? JSON.parse(data) : data;
-
- // Forward all messages to the main websocket
- ws.send(JSON.stringify(message));
-
- // Check for completion
- if (message.type === 'end') {
- backupCompleted = true;
- backupSucceeded = !message.data.includes('failed') && !message.data.includes('exit code:');
- if (!backupSucceeded) {
- // Backup failed, but we'll still allow update (per requirement 1b)
- ws.send(JSON.stringify({
- type: 'output',
- data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
- timestamp: Date.now()
- }));
- }
- resolve();
- } else if (message.type === 'error' && message.data.includes('Backup failed')) {
- backupCompleted = true;
- backupSucceeded = false;
- ws.send(JSON.stringify({
- type: 'output',
- data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
- timestamp: Date.now()
- }));
- resolve();
- }
- } catch (e) {
- // If parsing fails, just forward the raw data
- ws.send(data);
- }
- }
- };
-
- // Start backup execution
- this.startSSHBackupExecution(backupWs, containerId, backupExecutionId, backupStorage, server)
- .catch((error) => {
- // Backup failed to start, but allow update to proceed
- if (!backupCompleted) {
- backupCompleted = true;
- backupSucceeded = false;
- ws.send(JSON.stringify({
- type: 'output',
- data: `\n⚠️ Backup error: ${error.message}. Proceeding with update...\n`,
- timestamp: Date.now()
- }));
- resolve();
- }
+ try {
+ const backupResult = await this.startSSHBackupExecution(
+ ws,
+ containerId,
+ backupExecutionId,
+ backupStorage,
+ server
+ );
+
+ // Backup completed (successfully or not)
+ if (!backupResult || !backupResult.success) {
+ // Backup failed, but we'll still allow update (per requirement 1b)
+ this.sendMessage(ws, {
+ type: 'output',
+ data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
+ timestamp: Date.now()
});
- });
-
+ } else {
+ // Backup succeeded
+ this.sendMessage(ws, {
+ type: 'output',
+ data: '\n✅ Backup completed successfully. Starting update...\n',
+ timestamp: Date.now()
+ });
+ }
+ } catch (error) {
+ console.error('Backup error before update:', error);
+ // Backup failed to start, but allow update to proceed
+ this.sendMessage(ws, {
+ type: 'output',
+ data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`,
+ timestamp: Date.now()
+ });
+ }
+
// Small delay before starting update
await new Promise(resolve => setTimeout(resolve, 1000));
}
- // Send start message for update
+ // Send start message for update (only if we're actually starting an update)
this.sendMessage(ws, {
type: 'start',
data: `Starting update for container ${containerId}...`,
diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx
index a634598..ad155b4 100644
--- a/src/app/_components/InstalledScriptsTab.tsx
+++ b/src/app/_components/InstalledScriptsTab.tsx
@@ -61,6 +61,7 @@ export function InstalledScriptsTab() {
const [backupStorages, setBackupStorages] = useState([]);
const [isLoadingStorages, setIsLoadingStorages] = useState(false);
const [showBackupWarning, setShowBackupWarning] = useState(false);
+ const [isPreUpdateBackup, setIsPreUpdateBackup] = useState(false); // Track if storage selection is for pre-update backup
const [editingScriptId, setEditingScriptId] = useState(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
const [showAddForm, setShowAddForm] = useState(false);
@@ -660,6 +661,7 @@ export function InstalledScriptsTab() {
if (wantsBackup) {
// User wants backup - fetch storages and show selection
if (pendingUpdateScript.server_id) {
+ setIsPreUpdateBackup(true); // Mark that this is for pre-update backup
void fetchStorages(pendingUpdateScript.server_id, false);
setShowStorageSelection(true);
} else {
@@ -682,12 +684,13 @@ export function InstalledScriptsTab() {
setShowStorageSelection(false);
// Check if this is for a standalone backup or pre-update backup
- if (pendingUpdateScript && !showBackupPrompt) {
+ if (isPreUpdateBackup) {
+ // Pre-update backup - proceed with update
+ setIsPreUpdateBackup(false); // Reset flag
+ proceedWithUpdate(storage.name);
+ } else if (pendingUpdateScript) {
// Standalone backup - execute backup directly
executeStandaloneBackup(pendingUpdateScript, storage.name);
- } else {
- // Pre-update backup - proceed with update
- proceedWithUpdate(storage.name);
}
};
@@ -718,6 +721,7 @@ export function InstalledScriptsTab() {
});
// Reset state
+ setIsPreUpdateBackup(false); // Reset flag
setPendingUpdateScript(null);
setBackupStorages([]);
};
@@ -745,7 +749,8 @@ export function InstalledScriptsTab() {
id: pendingUpdateScript.id,
containerId: pendingUpdateScript.container_id!,
server: server,
- backupStorage: backupStorage ?? undefined
+ backupStorage: backupStorage ?? undefined,
+ isBackupOnly: false // Explicitly set to false for update operations
});
// Reset state
@@ -779,6 +784,7 @@ export function InstalledScriptsTab() {
}
// Store the script and fetch storages
+ setIsPreUpdateBackup(false); // This is a standalone backup, not pre-update
setPendingUpdateScript(script);
void fetchStorages(script.server_id, false);
setShowStorageSelection(true);
diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts
index e0273d9..927d638 100644
--- a/src/server/api/routers/installedScripts.ts
+++ b/src/server/api/routers/installedScripts.ts
@@ -4,7 +4,6 @@ import { getDatabase } from "~/server/database-prisma";
import { createHash } from "crypto";
import type { Server } from "~/types/server";
import { getStorageService } from "~/server/services/storageService";
-import { SSHService } from "~/server/ssh-service";
// Helper function to parse raw LXC config into structured data
function parseRawConfig(rawConfig: string): any {
@@ -2063,6 +2062,7 @@ EOFCONFIG`;
}
const storageService = getStorageService();
+ const { default: SSHService } = await import('~/server/ssh-service');
const sshService = new SSHService();
// Test SSH connection first
@@ -2118,6 +2118,7 @@ EOFCONFIG`;
};
}
+ const { default: SSHService } = await import('~/server/ssh-service');
const sshService = new SSHService();
// Test SSH connection first
diff --git a/src/server/services/storageService.ts b/src/server/services/storageService.ts
index c1c3bfb..4365759 100644
--- a/src/server/services/storageService.ts
+++ b/src/server/services/storageService.ts
@@ -29,7 +29,12 @@ class StorageService {
let currentStorage: Partial | null = null;
for (let i = 0; i < lines.length; i++) {
- const line = lines[i].trim();
+ const rawLine = lines[i];
+ if (!rawLine) continue;
+
+ // Check if line is indented (has leading whitespace/tabs) BEFORE trimming
+ const isIndented = /^[\s\t]/.test(rawLine);
+ const line = rawLine.trim();
// Skip empty lines and comments
if (!line || line.startsWith('#')) {
@@ -37,29 +42,34 @@ class StorageService {
}
// Check if this is a storage definition line (format: "type: name")
- const storageMatch = line.match(/^(\w+):\s*(.+)$/);
- if (storageMatch) {
- // Save previous storage if exists
- if (currentStorage && currentStorage.name) {
- storages.push(this.finalizeStorage(currentStorage));
+ // Storage definitions are NOT indented
+ if (!isIndented) {
+ const storageMatch = line.match(/^(\w+):\s*(.+)$/);
+ if (storageMatch && storageMatch[1] && storageMatch[2]) {
+ // Save previous storage if exists
+ if (currentStorage && currentStorage.name) {
+ storages.push(this.finalizeStorage(currentStorage));
+ }
+
+ // Start new storage
+ currentStorage = {
+ type: storageMatch[1],
+ name: storageMatch[2],
+ content: [],
+ supportsBackup: false,
+ };
+ continue;
}
-
- // Start new storage
- currentStorage = {
- type: storageMatch[1],
- name: storageMatch[2],
- content: [],
- supportsBackup: false,
- };
- continue;
}
- // Parse storage properties (indented lines)
- if (currentStorage && /^\s/.test(line)) {
- const propertyMatch = line.match(/^\s+(\w+)\s+(.+)$/);
- if (propertyMatch) {
- const key = propertyMatch[1];
- const value = propertyMatch[2];
+ // Parse storage properties (indented lines - can be tabs or spaces)
+ if (currentStorage && isIndented) {
+ // Split on first whitespace (space or tab) to separate key and value
+ const match = line.match(/^(\S+)\s+(.+)$/);
+
+ if (match && match[1] && match[2]) {
+ const key = match[1];
+ const value = match[2].trim();
switch (key) {
case 'content':
@@ -73,7 +83,9 @@ class StorageService {
break;
default:
// Store other properties
- (currentStorage as any)[key] = value;
+ if (key) {
+ (currentStorage as any)[key] = value;
+ }
}
}
}
From 4a50da496848fbb6c0633294ed9e05ea79fb4293 Mon Sep 17 00:00:00 2001
From: Michel Roegl-Brunner
Date: Fri, 14 Nov 2025 13:04:59 +0100
Subject: [PATCH 3/9] 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//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
---
prisma/schema.prisma | 20 +
src/app/_components/BackupsTab.tsx | 260 ++++++++++
src/app/page.tsx | 31 +-
src/server/api/root.ts | 2 +
src/server/api/routers/backups.ts | 90 ++++
src/server/api/routers/installedScripts.ts | 52 +-
src/server/database-prisma.js | 90 ++++
src/server/database-prisma.ts | 119 +++++
src/server/services/backupService.ts | 534 +++++++++++++++++++++
9 files changed, 1192 insertions(+), 6 deletions(-)
create mode 100644 src/app/_components/BackupsTab.tsx
create mode 100644 src/server/api/routers/backups.ts
create mode 100644 src/server/services/backupService.ts
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
)}
+
+ {/* Restore Confirmation Modal */}
+ {selectedBackup && (
+ {
+ setRestoreConfirmOpen(false);
+ setSelectedBackup(null);
+ }}
+ onConfirm={handleRestoreConfirm}
+ title="Restore Backup"
+ message={`This will destroy the existing container and restore from backup. The container will be stopped during restore. This action cannot be undone and may result in data loss.`}
+ variant="danger"
+ confirmText={selectedBackup.containerId}
+ confirmButtonText="Restore"
+ cancelButtonText="Cancel"
+ />
+ )}
+
+ {/* Restore Progress Modal */}
+ {restoreMutation.isPending && (
+
+ )}
+
+ {/* Restore Progress Details - Show during restore */}
+ {restoreMutation.isPending && (
+
+
+
+ Restoring backup...
+
+ {restoreProgress.length > 0 && (
+
+ {restoreProgress.map((message, index) => (
+
+ {message}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Restore Success */}
+ {restoreSuccess && (
+
+
+
+ Restore completed successfully!
+
+ {restoreProgress.length > 0 && (
+
+ {restoreProgress.map((message, index) => (
+
+ {message}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Restore Error */}
+ {restoreError && (
+
+
+
{restoreError}
+ {restoreProgress.length > 0 && (
+
+ {restoreProgress.map((message, index) => (
+
+ {message}
+
+ ))}
+
+ )}
+
+
+ )}
);
}
diff --git a/src/server/api/routers/backups.ts b/src/server/api/routers/backups.ts
index 78f0f09..7517a2c 100644
--- a/src/server/api/routers/backups.ts
+++ b/src/server/api/routers/backups.ts
@@ -2,6 +2,7 @@ import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '~/server/api/trpc';
import { getDatabase } from '~/server/database-prisma';
import { getBackupService } from '~/server/services/backupService';
+import { getRestoreService } from '~/server/services/restoreService';
export const backupsRouter = createTRPCRouter({
// Get all backups grouped by container ID
@@ -47,6 +48,7 @@ export const backupsRouter = createTRPCRouter({
storage_name: backup.storage_name,
storage_type: backup.storage_type,
discovered_at: backup.discovered_at,
+ server_id: backup.server_id,
server_name: backup.server?.name ?? null,
server_color: backup.server?.color ?? null,
})),
@@ -86,5 +88,36 @@ export const backupsRouter = createTRPCRouter({
};
}
}),
+
+ // Restore backup
+ restoreBackup: publicProcedure
+ .input(z.object({
+ backupId: z.number(),
+ containerId: z.string(),
+ serverId: z.number(),
+ }))
+ .mutation(async ({ input }) => {
+ try {
+ const restoreService = getRestoreService();
+ const result = await restoreService.executeRestore(
+ input.backupId,
+ input.containerId,
+ input.serverId
+ );
+
+ return {
+ success: result.success,
+ error: result.error,
+ progress: result.progress,
+ };
+ } catch (error) {
+ console.error('Error in restoreBackup:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to restore backup',
+ progress: [],
+ };
+ }
+ }),
});
diff --git a/src/server/database-prisma.js b/src/server/database-prisma.js
index 951765f..51f6461 100644
--- a/src/server/database-prisma.js
+++ b/src/server/database-prisma.js
@@ -327,6 +327,15 @@ class DatabaseServicePrisma {
});
}
+ async getBackupById(id) {
+ return await prisma.backup.findUnique({
+ where: { id },
+ include: {
+ server: true,
+ },
+ });
+ }
+
async getBackupsByContainerId(containerId) {
return await prisma.backup.findMany({
where: { container_id: containerId },
diff --git a/src/server/database-prisma.ts b/src/server/database-prisma.ts
index 471f623..37d9cca 100644
--- a/src/server/database-prisma.ts
+++ b/src/server/database-prisma.ts
@@ -364,6 +364,15 @@ class DatabaseServicePrisma {
});
}
+ async getBackupById(id: number) {
+ return await prisma.backup.findUnique({
+ where: { id },
+ include: {
+ server: true,
+ },
+ });
+ }
+
async getBackupsByContainerId(containerId: string) {
return await prisma.backup.findMany({
where: { container_id: containerId },
diff --git a/src/server/services/restoreService.ts b/src/server/services/restoreService.ts
new file mode 100644
index 0000000..a844b84
--- /dev/null
+++ b/src/server/services/restoreService.ts
@@ -0,0 +1,568 @@
+import { getSSHExecutionService } from '../ssh-execution-service';
+import { getBackupService } from './backupService';
+import { getStorageService } from './storageService';
+import { getDatabase } from '../database-prisma';
+import type { Server } from '~/types/server';
+import type { Storage } from './storageService';
+
+export interface RestoreProgress {
+ step: string;
+ message: string;
+}
+
+export interface RestoreResult {
+ success: boolean;
+ error?: string;
+ progress?: RestoreProgress[];
+}
+
+class RestoreService {
+ /**
+ * Get rootfs storage from LXC config or installed scripts database
+ */
+ async getRootfsStorage(server: Server, ctId: string): Promise {
+ const sshService = getSSHExecutionService();
+ const db = getDatabase();
+ const configPath = `/etc/pve/lxc/${ctId}.conf`;
+ const readCommand = `cat "${configPath}" 2>/dev/null || echo ""`;
+ let rawConfig = '';
+
+ try {
+ // Try to read config file (container might not exist, so don't fail on error)
+ await new Promise((resolve) => {
+ sshService.executeCommand(
+ server,
+ readCommand,
+ (data: string) => {
+ rawConfig += data;
+ },
+ () => resolve(), // Don't fail on error
+ () => resolve() // Always resolve
+ );
+ });
+
+ // If we got config content, parse it
+ if (rawConfig.trim()) {
+ // Parse rootfs line: rootfs: PROX2-STORAGE2:vm-148-disk-0,size=4G
+ const lines = rawConfig.split('\n');
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.startsWith('rootfs:')) {
+ const match = trimmed.match(/^rootfs:\s*([^:]+):/);
+ if (match && match[1]) {
+ return match[1].trim();
+ }
+ }
+ }
+ }
+
+ // If config file doesn't exist or doesn't have rootfs, try to get from installed scripts database
+ const installedScripts = await db.getAllInstalledScripts();
+ const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
+
+ if (script) {
+ // Try to get LXC config from database
+ const lxcConfig = await db.getLXCConfigByScriptId(script.id);
+ if (lxcConfig?.rootfs_storage) {
+ // Extract storage from rootfs_storage format: "STORAGE:vm-148-disk-0"
+ const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
+ if (match && match[1]) {
+ return match[1].trim();
+ }
+ }
+ }
+
+ return null;
+ } catch (error) {
+ console.error(`Error reading LXC config for CT ${ctId}:`, error);
+ // Try fallback to database
+ try {
+ const installedScripts = await db.getAllInstalledScripts();
+ const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
+ if (script) {
+ const lxcConfig = await db.getLXCConfigByScriptId(script.id);
+ if (lxcConfig?.rootfs_storage) {
+ const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
+ if (match && match[1]) {
+ return match[1].trim();
+ }
+ }
+ }
+ } catch (dbError) {
+ console.error(`Error getting storage from database:`, dbError);
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Stop container (continue if already stopped)
+ */
+ async stopContainer(server: Server, ctId: string): Promise {
+ const sshService = getSSHExecutionService();
+ const command = `pct stop ${ctId} 2>&1 || true`; // Continue even if already stopped
+
+ await new Promise((resolve) => {
+ sshService.executeCommand(
+ server,
+ command,
+ () => {},
+ () => resolve(),
+ () => resolve() // Always resolve, don't fail if already stopped
+ );
+ });
+ }
+
+ /**
+ * Destroy container
+ */
+ async destroyContainer(server: Server, ctId: string): Promise {
+ const sshService = getSSHExecutionService();
+ const command = `pct destroy ${ctId} 2>&1`;
+ let output = '';
+ let exitCode = 0;
+
+ await new Promise((resolve, reject) => {
+ sshService.executeCommand(
+ server,
+ command,
+ (data: string) => {
+ output += data;
+ },
+ (error: string) => {
+ // Check if error is about container not existing
+ if (error.includes('does not exist') || error.includes('not found')) {
+ console.log(`[RestoreService] Container ${ctId} does not exist`);
+ resolve(); // Container doesn't exist, that's fine
+ } else {
+ reject(new Error(`Destroy failed: ${error}`));
+ }
+ },
+ (code: number) => {
+ exitCode = code;
+ if (exitCode === 0) {
+ resolve();
+ } else {
+ // Check if error is about container not existing
+ if (output.includes('does not exist') || output.includes('not found') || output.includes('No such file')) {
+ console.log(`[RestoreService] Container ${ctId} does not exist`);
+ resolve(); // Container doesn't exist, that's fine
+ } else {
+ reject(new Error(`Destroy failed with exit code ${exitCode}: ${output}`));
+ }
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * Restore from local/storage backup
+ */
+ async restoreLocalBackup(
+ server: Server,
+ ctId: string,
+ backupPath: string,
+ storage: string
+ ): Promise {
+ const sshService = getSSHExecutionService();
+ const command = `pct restore ${ctId} "${backupPath}" --storage=${storage}`;
+ let output = '';
+ let exitCode = 0;
+
+ await new Promise((resolve, reject) => {
+ sshService.executeCommand(
+ server,
+ command,
+ (data: string) => {
+ output += data;
+ },
+ (error: string) => {
+ reject(new Error(`Restore failed: ${error}`));
+ },
+ (code: number) => {
+ exitCode = code;
+ if (exitCode === 0) {
+ resolve();
+ } else {
+ reject(new Error(`Restore failed with exit code ${exitCode}: ${output}`));
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * Restore from PBS backup
+ */
+ async restorePBSBackup(
+ server: Server,
+ storage: Storage,
+ ctId: string,
+ snapshotPath: string,
+ storageName: string,
+ onProgress?: (step: string, message: string) => void
+ ): Promise {
+ const backupService = getBackupService();
+ const sshService = getSSHExecutionService();
+ const db = getDatabase();
+
+ // Get PBS credentials
+ const credential = await db.getPBSCredential(server.id, storage.name);
+ if (!credential) {
+ throw new Error(`No PBS credentials found for storage ${storage.name}`);
+ }
+
+ const storageService = getStorageService();
+ const pbsInfo = storageService.getPBSStorageInfo(storage);
+ const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
+ const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
+
+ if (!pbsIp || !pbsDatastore) {
+ throw new Error(`Missing PBS IP or datastore for storage ${storage.name}`);
+ }
+
+ const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
+
+ // Extract snapshot name from path (e.g., "2025-10-21T19:14:55Z" from "ct/148/2025-10-21T19:14:55Z")
+ const snapshotParts = snapshotPath.split('/');
+ const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
+ // Replace colons with underscores for file paths (tar doesn't like colons in filenames)
+ const snapshotNameForPath = snapshotName.replace(/:/g, '_');
+
+ // Determine file extension - try common extensions
+ const extensions = ['.tar', '.tar.zst', '.pxar'];
+ let downloadedPath = '';
+ let downloadSuccess = false;
+
+ // Login to PBS first
+ if (onProgress) onProgress('pbs_login', 'Logging into PBS...');
+ console.log(`[RestoreService] Logging into PBS for storage ${storage.name}`);
+ const loggedIn = await backupService.loginToPBS(server, storage);
+ if (!loggedIn) {
+ throw new Error(`Failed to login to PBS for storage ${storage.name}`);
+ }
+ console.log(`[RestoreService] PBS login successful`);
+
+ // Download backup from PBS
+ // proxmox-backup-client restore outputs a folder, not a file
+ if (onProgress) onProgress('pbs_download', 'Downloading backup from PBS...');
+ console.log(`[RestoreService] Starting download of snapshot ${snapshotPath}`);
+
+ // Target folder for PBS restore (without extension)
+ // Use sanitized snapshot name (colons replaced with underscores) for file paths
+ const targetFolder = `/var/lib/vz/dump/vzdump-lxc-${ctId}-${snapshotNameForPath}`;
+ const targetTar = `${targetFolder}.tar`;
+
+ // Use PBS_PASSWORD env var and add timeout for long downloads
+ const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
+ const restoreCommand = `PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 300 proxmox-backup-client restore "${snapshotPath}" root.pxar "${targetFolder}" --repository '${repository}' 2>&1`;
+
+ let output = '';
+ let exitCode = 0;
+
+ try {
+ // Download from PBS (creates a folder)
+ await Promise.race([
+ new Promise((resolve, reject) => {
+ sshService.executeCommand(
+ server,
+ restoreCommand,
+ (data: string) => {
+ output += data;
+ console.log(`[RestoreService] Download output: ${data}`);
+ },
+ (error: string) => {
+ console.error(`[RestoreService] Download error: ${error}`);
+ reject(new Error(`Download failed: ${error}`));
+ },
+ (code: number) => {
+ exitCode = code;
+ console.log(`[RestoreService] Download command exited with code ${exitCode}`);
+ if (exitCode === 0) {
+ resolve();
+ } else {
+ console.error(`[RestoreService] Download failed: ${output}`);
+ reject(new Error(`Download failed with exit code ${exitCode}: ${output}`));
+ }
+ }
+ );
+ }),
+ new Promise((resolve, reject) => {
+ setTimeout(() => {
+ reject(new Error('Download timeout after 5 minutes'));
+ }, 300000); // 5 minute timeout
+ })
+ ]);
+
+ // Check if folder exists
+ const checkCommand = `test -d "${targetFolder}" && echo "exists" || echo "notfound"`;
+ let checkOutput = '';
+
+ await new Promise((resolve) => {
+ sshService.executeCommand(
+ server,
+ checkCommand,
+ (data: string) => {
+ checkOutput += data;
+ },
+ () => resolve(),
+ () => resolve()
+ );
+ });
+
+ console.log(`[RestoreService] Folder check result: ${checkOutput}`);
+
+ if (!checkOutput.includes('exists')) {
+ throw new Error(`Downloaded folder ${targetFolder} does not exist`);
+ }
+
+ // Pack the folder into a tar file
+ if (onProgress) onProgress('pbs_pack', 'Packing backup folder...');
+ console.log(`[RestoreService] Packing folder ${targetFolder} into ${targetTar}`);
+
+ // Use -C to change to the folder directory, then pack all contents (.) into the tar file
+ const packCommand = `tar -cf "${targetTar}" -C "${targetFolder}" . 2>&1`;
+ let packOutput = '';
+ let packExitCode = 0;
+
+ await Promise.race([
+ new Promise((resolve, reject) => {
+ sshService.executeCommand(
+ server,
+ packCommand,
+ (data: string) => {
+ packOutput += data;
+ console.log(`[RestoreService] Pack output: ${data}`);
+ },
+ (error: string) => {
+ console.error(`[RestoreService] Pack error: ${error}`);
+ reject(new Error(`Pack failed: ${error}`));
+ },
+ (code: number) => {
+ packExitCode = code;
+ console.log(`[RestoreService] Pack command exited with code ${packExitCode}`);
+ if (packExitCode === 0) {
+ resolve();
+ } else {
+ console.error(`[RestoreService] Pack failed: ${packOutput}`);
+ reject(new Error(`Pack failed with exit code ${packExitCode}: ${packOutput}`));
+ }
+ }
+ );
+ }),
+ new Promise((resolve, reject) => {
+ setTimeout(() => {
+ reject(new Error('Pack timeout after 2 minutes'));
+ }, 120000); // 2 minute timeout for packing
+ })
+ ]);
+
+ // Check if tar file exists
+ const checkTarCommand = `test -f "${targetTar}" && echo "exists" || echo "notfound"`;
+ let checkTarOutput = '';
+
+ await new Promise((resolve) => {
+ sshService.executeCommand(
+ server,
+ checkTarCommand,
+ (data: string) => {
+ checkTarOutput += data;
+ },
+ () => resolve(),
+ () => resolve()
+ );
+ });
+
+ console.log(`[RestoreService] Tar file check result: ${checkTarOutput}`);
+
+ if (!checkTarOutput.includes('exists')) {
+ throw new Error(`Packed tar file ${targetTar} does not exist`);
+ }
+
+ downloadedPath = targetTar;
+ downloadSuccess = true;
+ console.log(`[RestoreService] Successfully downloaded and packed backup to ${targetTar}`);
+
+ } catch (error) {
+ console.error(`[RestoreService] Failed to download/pack backup:`, error);
+ throw error;
+ }
+
+ if (!downloadSuccess || !downloadedPath) {
+ throw new Error(`Failed to download and pack backup from PBS`);
+ }
+
+ // Restore from packed tar file
+ if (onProgress) onProgress('restoring', 'Restoring container...');
+ try {
+ console.log(`[RestoreService] Starting Restore from ${targetTar}`);
+ await this.restoreLocalBackup(server, ctId, downloadedPath, storageName);
+ } finally {
+ // Cleanup: delete downloaded folder and tar file
+ if (onProgress) onProgress('cleanup', 'Cleaning up temporary files...');
+ const cleanupCommand = `rm -rf "${targetFolder}" "${targetTar}" 2>&1 || true`;
+ sshService.executeCommand(
+ server,
+ cleanupCommand,
+ () => {},
+ () => {},
+ () => {}
+ );
+ }
+ }
+
+ /**
+ * Execute full restore flow
+ */
+ async executeRestore(
+ backupId: number,
+ containerId: string,
+ serverId: number,
+ onProgress?: (progress: RestoreProgress) => void
+ ): Promise {
+ const progress: RestoreProgress[] = [];
+
+ const addProgress = (step: string, message: string) => {
+ const p = { step, message };
+ progress.push(p);
+ if (onProgress) {
+ onProgress(p);
+ }
+ };
+
+ try {
+ const db = getDatabase();
+ const sshService = getSSHExecutionService();
+
+ console.log(`[RestoreService] Starting restore for backup ${backupId}, CT ${containerId}, server ${serverId}`);
+
+ // Get backup details
+ const backup = await db.getBackupById(backupId);
+ if (!backup) {
+ throw new Error(`Backup with ID ${backupId} not found`);
+ }
+
+ console.log(`[RestoreService] Backup found: ${backup.backup_name}, type: ${backup.storage_type}, path: ${backup.backup_path}`);
+
+ // Get server details
+ const server = await db.getServerById(serverId);
+ if (!server) {
+ throw new Error(`Server with ID ${serverId} not found`);
+ }
+
+ // Get rootfs storage
+ addProgress('reading_config', 'Reading container configuration...');
+ console.log(`[RestoreService] Getting rootfs storage for CT ${containerId}`);
+ const rootfsStorage = await this.getRootfsStorage(server, containerId);
+ console.log(`[RestoreService] Rootfs storage: ${rootfsStorage || 'not found'}`);
+
+ if (!rootfsStorage) {
+ // Try to check if container exists, if not we can proceed without stopping/destroying
+ const checkCommand = `pct list ${containerId} 2>&1 | grep -q "^${containerId}" && echo "exists" || echo "notfound"`;
+ let checkOutput = '';
+ await new Promise((resolve) => {
+ sshService.executeCommand(
+ server,
+ checkCommand,
+ (data: string) => {
+ checkOutput += data;
+ },
+ () => resolve(),
+ () => resolve()
+ );
+ });
+
+ if (checkOutput.includes('notfound')) {
+ // Container doesn't exist, we can't determine storage - need user input or use default
+ throw new Error(`Container ${containerId} does not exist and storage could not be determined. Please ensure the container exists or specify the storage manually.`);
+ }
+
+ throw new Error(`Could not determine rootfs storage for container ${containerId}. Please ensure the container exists and has a valid configuration.`);
+ }
+
+ // Try to stop and destroy container - if it doesn't exist, continue anyway
+ addProgress('stopping', 'Stopping container...');
+ try {
+ await this.stopContainer(server, containerId);
+ console.log(`[RestoreService] Container ${containerId} stopped`);
+ } catch (error) {
+ console.warn(`[RestoreService] Failed to stop container (may not exist or already stopped):`, error);
+ // Continue even if stop fails
+ }
+
+ // Try to destroy container - if it doesn't exist, continue anyway
+ addProgress('destroying', 'Destroying container...');
+ try {
+ await this.destroyContainer(server, containerId);
+ console.log(`[RestoreService] Container ${containerId} destroyed successfully`);
+ } catch (error) {
+ // Container might not exist, which is fine - continue with restore
+ console.log(`[RestoreService] Container ${containerId} does not exist or destroy failed (continuing anyway):`, error);
+ addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
+ }
+
+ // Restore based on backup type
+ if (backup.storage_type === 'pbs') {
+ console.log(`[RestoreService] Restoring from PBS backup`);
+ // Get storage info for PBS
+ const storageService = getStorageService();
+ const storages = await storageService.getStorages(server, false);
+ const storage = storages.find(s => s.name === backup.storage_name);
+
+ if (!storage) {
+ throw new Error(`Storage ${backup.storage_name} not found`);
+ }
+
+ // Parse snapshot path from backup_path (format: pbs://root@pam@IP:DATASTORE/ct/148/2025-10-21T19:14:55Z)
+ const snapshotPathMatch = backup.backup_path.match(/pbs:\/\/[^/]+\/(.+)$/);
+ if (!snapshotPathMatch || !snapshotPathMatch[1]) {
+ throw new Error(`Invalid PBS backup path format: ${backup.backup_path}`);
+ }
+
+ const snapshotPath = snapshotPathMatch[1];
+ console.log(`[RestoreService] Snapshot path: ${snapshotPath}, Storage: ${rootfsStorage}`);
+
+ await this.restorePBSBackup(server, storage, containerId, snapshotPath, rootfsStorage, (step, message) => {
+ addProgress(step, message);
+ });
+ } else {
+ // Local or storage backup
+ console.log(`[RestoreService] Restoring from ${backup.storage_type} backup: ${backup.backup_path}`);
+ addProgress('restoring', 'Restoring container...');
+ await this.restoreLocalBackup(server, containerId, backup.backup_path, rootfsStorage);
+ console.log(`[RestoreService] Local restore completed`);
+ }
+
+ addProgress('complete', 'Restore completed successfully');
+
+ console.log(`[RestoreService] Restore completed successfully for CT ${containerId}`);
+
+ return {
+ success: true,
+ progress,
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
+ console.error(`[RestoreService] Restore failed for CT ${containerId}:`, error);
+ addProgress('error', `Error: ${errorMessage}`);
+
+ return {
+ success: false,
+ error: errorMessage,
+ progress,
+ };
+ }
+ }
+}
+
+// Singleton instance
+let restoreServiceInstance: RestoreService | null = null;
+
+export function getRestoreService(): RestoreService {
+ if (!restoreServiceInstance) {
+ restoreServiceInstance = new RestoreService();
+ }
+ return restoreServiceInstance;
+}
+
From 570eea41b93724d3a37b3050b711e7424e74f994 Mon Sep 17 00:00:00 2001
From: Michel Roegl-Brunner
Date: Fri, 14 Nov 2025 15:43:33 +0100
Subject: [PATCH 7/9] Implement real-time restore progress updates with polling
- Add restore.log file writing in restoreService.ts for progress tracking
- Create getRestoreProgress query endpoint for polling restore logs
- Implement polling-based progress updates in BackupsTab (1 second interval)
- Update LoadingModal to display all progress logs with auto-scroll
- Remove console.log debug output from restoreService
- Add static 'Restore in progress' text under spinner
- Show success checkmark when restore completes
- Prevent modal dismissal during restore, allow ESC/X button when complete
- Remove step prefixes from log messages for cleaner output
- Keep success/error modals open until user dismisses manually
---
restore.log | 10 ++
src/app/_components/BackupsTab.tsx | 140 +++++++++++++++++---------
src/app/_components/LoadingModal.tsx | 78 +++++++++++---
src/server/api/routers/backups.ts | 47 +++++++++
src/server/services/restoreService.ts | 133 ++++++++++++------------
5 files changed, 276 insertions(+), 132 deletions(-)
create mode 100644 restore.log
diff --git a/restore.log b/restore.log
new file mode 100644
index 0000000..0f654fb
--- /dev/null
+++ b/restore.log
@@ -0,0 +1,10 @@
+Starting restore...
+Reading container configuration...
+Stopping container...
+Destroying container...
+Logging into PBS...
+Downloading backup from PBS...
+Packing backup folder...
+Restoring container...
+Cleaning up temporary files...
+Restore completed successfully
diff --git a/src/app/_components/BackupsTab.tsx b/src/app/_components/BackupsTab.tsx
index daaee40..2e24b7b 100644
--- a/src/app/_components/BackupsTab.tsx
+++ b/src/app/_components/BackupsTab.tsx
@@ -42,6 +42,7 @@ export function BackupsTab() {
const [restoreProgress, setRestoreProgress] = useState([]);
const [restoreSuccess, setRestoreSuccess] = useState(false);
const [restoreError, setRestoreError] = useState(null);
+ const [shouldPollRestore, setShouldPollRestore] = useState(false);
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
const discoverMutation = api.backups.discoverBackups.useMutation({
@@ -50,32 +51,67 @@ export function BackupsTab() {
},
});
+ // Poll for restore progress
+ const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
+ enabled: shouldPollRestore,
+ refetchInterval: 1000, // Poll every second
+ refetchIntervalInBackground: true,
+ });
+
+ // Update restore progress when log data changes
+ useEffect(() => {
+ if (restoreLogsData?.success && restoreLogsData.logs) {
+ setRestoreProgress(restoreLogsData.logs);
+
+ // Stop polling when restore is complete
+ if (restoreLogsData.isComplete) {
+ setShouldPollRestore(false);
+ // Check if restore was successful or failed
+ const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
+ if (lastLog.includes('Restore completed successfully')) {
+ setRestoreSuccess(true);
+ setRestoreError(null);
+ } else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
+ setRestoreError(lastLog);
+ setRestoreSuccess(false);
+ }
+ }
+ }
+ }, [restoreLogsData]);
+
const restoreMutation = api.backups.restoreBackup.useMutation({
onMutate: () => {
- // Show progress immediately when mutation starts
+ // Start polling for progress
+ setShouldPollRestore(true);
setRestoreProgress(['Starting restore...']);
setRestoreError(null);
setRestoreSuccess(false);
},
onSuccess: (result) => {
+ // Stop polling - progress will be updated from logs
+ setShouldPollRestore(false);
+
if (result.success) {
- setRestoreSuccess(true);
- // Update progress with all messages from backend
- const progressMessages = result.progress?.map(p => p.message) || ['Restore completed successfully'];
+ // Update progress with all messages from backend (fallback if polling didn't work)
+ const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']);
setRestoreProgress(progressMessages);
+ setRestoreSuccess(true);
+ setRestoreError(null);
setRestoreConfirmOpen(false);
setSelectedBackup(null);
- // Clear success message after 5 seconds
- setTimeout(() => {
- setRestoreSuccess(false);
- setRestoreProgress([]);
- }, 5000);
+ // Keep success message visible - user can dismiss manually
} else {
setRestoreError(result.error || 'Restore failed');
- setRestoreProgress(result.progress?.map(p => p.message) || []);
+ setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
+ setRestoreSuccess(false);
+ setRestoreConfirmOpen(false);
+ setSelectedBackup(null);
+ // Keep error message visible - user can dismiss manually
}
},
onError: (error) => {
+ // Stop polling on error
+ setShouldPollRestore(false);
setRestoreError(error.message || 'Restore failed');
setRestoreConfirmOpen(false);
setSelectedBackup(null);
@@ -376,59 +412,69 @@ export function BackupsTab() {
)}
{/* Restore Progress Modal */}
- {restoreMutation.isPending && (
+ {(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
{
+ setRestoreSuccess(false);
+ setRestoreProgress([]);
+ }}
/>
)}
- {/* Restore Progress Details - Show during restore */}
- {restoreMutation.isPending && (
-
-
-
- Restoring backup...
-
- {restoreProgress.length > 0 && (
-
- {restoreProgress.map((message, index) => (
-
- {message}
-
- ))}
-
- )}
-
- )}
-
{/* Restore Success */}
{restoreSuccess && (
-
-
- Restore completed successfully!
-
- {restoreProgress.length > 0 && (
-
- {restoreProgress.map((message, index) => (
-
- {message}
-
- ))}
+
+
+
+ Restore Completed Successfully
- )}
+
+
+
+ The container has been restored from backup.
+
)}
{/* Restore Error */}
{restoreError && (
-
-
-
-
Restore failed
+
+
+
+
-
{restoreError}
+
+ {restoreError}
+
{restoreProgress.length > 0 && (
{restoreProgress.map((message, index) => (
diff --git a/src/app/_components/LoadingModal.tsx b/src/app/_components/LoadingModal.tsx
index 00d57ac..2e1c3c8 100644
--- a/src/app/_components/LoadingModal.tsx
+++ b/src/app/_components/LoadingModal.tsx
@@ -1,36 +1,84 @@
'use client';
-import { Loader2 } from 'lucide-react';
+import { Loader2, CheckCircle, X } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
+import { useEffect, useRef } from 'react';
+import { Button } from './ui/button';
interface LoadingModalProps {
isOpen: boolean;
action: string;
+ logs?: string[];
+ isComplete?: boolean;
+ title?: string;
+ onClose?: () => void;
}
-export function LoadingModal({ isOpen, action }: LoadingModalProps) {
- useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null });
+export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
+ // Allow dismissing with ESC only when complete, prevent during running
+ useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
+ const logsEndRef = useRef
(null);
+
+ // Auto-scroll to bottom when new logs arrive
+ useEffect(() => {
+ logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [logs]);
+
if (!isOpen) return null;
return (
-
+
+ {/* Close button - only show when complete */}
+ {isComplete && onClose && (
+
+ )}
+
-
-
+ {isComplete ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
-
-
- Processing
-
+
+ {/* Static title text */}
+ {title && (
- {action}
+ {title}
-
- Please wait...
-
-
+ )}
+
+ {/* Log output */}
+ {logs.length > 0 && (
+
+ {logs.map((log, index) => (
+
+ {log}
+
+ ))}
+
+
+ )}
+
+ {!isComplete && (
+
+ )}
diff --git a/src/server/api/routers/backups.ts b/src/server/api/routers/backups.ts
index 7517a2c..4b2ab08 100644
--- a/src/server/api/routers/backups.ts
+++ b/src/server/api/routers/backups.ts
@@ -3,6 +3,10 @@ import { createTRPCRouter, publicProcedure } from '~/server/api/trpc';
import { getDatabase } from '~/server/database-prisma';
import { getBackupService } from '~/server/services/backupService';
import { getRestoreService } from '~/server/services/restoreService';
+import { readFile } from 'fs/promises';
+import { join } from 'path';
+import { existsSync } from 'fs';
+import stripAnsi from 'strip-ansi';
export const backupsRouter = createTRPCRouter({
// Get all backups grouped by container ID
@@ -89,6 +93,49 @@ export const backupsRouter = createTRPCRouter({
}
}),
+ // Get restore progress from log file
+ getRestoreProgress: publicProcedure
+ .query(async () => {
+ try {
+ const logPath = join(process.cwd(), 'restore.log');
+
+ if (!existsSync(logPath)) {
+ return {
+ success: true,
+ logs: [],
+ isComplete: false
+ };
+ }
+
+ const logs = await readFile(logPath, 'utf-8');
+ const logLines = logs.split('\n')
+ .filter(line => line.trim())
+ .map(line => stripAnsi(line)); // Strip ANSI color codes
+
+ // Check if restore is complete by looking for completion indicators
+ const isComplete = logLines.some(line =>
+ line.includes('complete: Restore completed successfully') ||
+ line.includes('error: Error:') ||
+ line.includes('Restore completed successfully') ||
+ line.includes('Restore failed')
+ );
+
+ return {
+ success: true,
+ logs: logLines,
+ isComplete
+ };
+ } catch (error) {
+ console.error('Error reading restore logs:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to read restore logs',
+ logs: [],
+ isComplete: false
+ };
+ }
+ }),
+
// Restore backup
restoreBackup: publicProcedure
.input(z.object({
diff --git a/src/server/services/restoreService.ts b/src/server/services/restoreService.ts
index a844b84..935b949 100644
--- a/src/server/services/restoreService.ts
+++ b/src/server/services/restoreService.ts
@@ -4,6 +4,9 @@ import { getStorageService } from './storageService';
import { getDatabase } from '../database-prisma';
import type { Server } from '~/types/server';
import type { Storage } from './storageService';
+import { writeFile, readFile } from 'fs/promises';
+import { join } from 'path';
+import { existsSync } from 'fs';
export interface RestoreProgress {
step: string;
@@ -73,26 +76,25 @@ class RestoreService {
}
return null;
- } catch (error) {
- console.error(`Error reading LXC config for CT ${ctId}:`, error);
- // Try fallback to database
- try {
- const installedScripts = await db.getAllInstalledScripts();
- const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
- if (script) {
- const lxcConfig = await db.getLXCConfigByScriptId(script.id);
- if (lxcConfig?.rootfs_storage) {
- const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
- if (match && match[1]) {
- return match[1].trim();
+ } catch (error) {
+ // Try fallback to database
+ try {
+ const installedScripts = await db.getAllInstalledScripts();
+ const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
+ if (script) {
+ const lxcConfig = await db.getLXCConfigByScriptId(script.id);
+ if (lxcConfig?.rootfs_storage) {
+ const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
+ if (match && match[1]) {
+ return match[1].trim();
+ }
}
}
+ } catch (dbError) {
+ // Ignore database error
}
- } catch (dbError) {
- console.error(`Error getting storage from database:`, dbError);
+ return null;
}
- return null;
- }
}
/**
@@ -132,7 +134,6 @@ class RestoreService {
(error: string) => {
// Check if error is about container not existing
if (error.includes('does not exist') || error.includes('not found')) {
- console.log(`[RestoreService] Container ${ctId} does not exist`);
resolve(); // Container doesn't exist, that's fine
} else {
reject(new Error(`Destroy failed: ${error}`));
@@ -145,7 +146,6 @@ class RestoreService {
} else {
// Check if error is about container not existing
if (output.includes('does not exist') || output.includes('not found') || output.includes('No such file')) {
- console.log(`[RestoreService] Container ${ctId} does not exist`);
resolve(); // Container doesn't exist, that's fine
} else {
reject(new Error(`Destroy failed with exit code ${exitCode}: ${output}`));
@@ -201,7 +201,7 @@ class RestoreService {
ctId: string,
snapshotPath: string,
storageName: string,
- onProgress?: (step: string, message: string) => void
+ onProgress?: (step: string, message: string) => Promise
): Promise {
const backupService = getBackupService();
const sshService = getSSHExecutionService();
@@ -236,18 +236,15 @@ class RestoreService {
let downloadSuccess = false;
// Login to PBS first
- if (onProgress) onProgress('pbs_login', 'Logging into PBS...');
- console.log(`[RestoreService] Logging into PBS for storage ${storage.name}`);
+ if (onProgress) await onProgress('pbs_login', 'Logging into PBS...');
const loggedIn = await backupService.loginToPBS(server, storage);
if (!loggedIn) {
throw new Error(`Failed to login to PBS for storage ${storage.name}`);
}
- console.log(`[RestoreService] PBS login successful`);
// Download backup from PBS
// proxmox-backup-client restore outputs a folder, not a file
- if (onProgress) onProgress('pbs_download', 'Downloading backup from PBS...');
- console.log(`[RestoreService] Starting download of snapshot ${snapshotPath}`);
+ if (onProgress) await onProgress('pbs_download', 'Downloading backup from PBS...');
// Target folder for PBS restore (without extension)
// Use sanitized snapshot name (colons replaced with underscores) for file paths
@@ -270,19 +267,15 @@ class RestoreService {
restoreCommand,
(data: string) => {
output += data;
- console.log(`[RestoreService] Download output: ${data}`);
},
(error: string) => {
- console.error(`[RestoreService] Download error: ${error}`);
reject(new Error(`Download failed: ${error}`));
},
(code: number) => {
exitCode = code;
- console.log(`[RestoreService] Download command exited with code ${exitCode}`);
if (exitCode === 0) {
resolve();
} else {
- console.error(`[RestoreService] Download failed: ${output}`);
reject(new Error(`Download failed with exit code ${exitCode}: ${output}`));
}
}
@@ -311,15 +304,12 @@ class RestoreService {
);
});
- console.log(`[RestoreService] Folder check result: ${checkOutput}`);
-
if (!checkOutput.includes('exists')) {
throw new Error(`Downloaded folder ${targetFolder} does not exist`);
}
// Pack the folder into a tar file
- if (onProgress) onProgress('pbs_pack', 'Packing backup folder...');
- console.log(`[RestoreService] Packing folder ${targetFolder} into ${targetTar}`);
+ if (onProgress) await onProgress('pbs_pack', 'Packing backup folder...');
// Use -C to change to the folder directory, then pack all contents (.) into the tar file
const packCommand = `tar -cf "${targetTar}" -C "${targetFolder}" . 2>&1`;
@@ -333,19 +323,15 @@ class RestoreService {
packCommand,
(data: string) => {
packOutput += data;
- console.log(`[RestoreService] Pack output: ${data}`);
},
(error: string) => {
- console.error(`[RestoreService] Pack error: ${error}`);
reject(new Error(`Pack failed: ${error}`));
},
(code: number) => {
packExitCode = code;
- console.log(`[RestoreService] Pack command exited with code ${packExitCode}`);
if (packExitCode === 0) {
resolve();
} else {
- console.error(`[RestoreService] Pack failed: ${packOutput}`);
reject(new Error(`Pack failed with exit code ${packExitCode}: ${packOutput}`));
}
}
@@ -374,18 +360,13 @@ class RestoreService {
);
});
- console.log(`[RestoreService] Tar file check result: ${checkTarOutput}`);
-
if (!checkTarOutput.includes('exists')) {
throw new Error(`Packed tar file ${targetTar} does not exist`);
}
downloadedPath = targetTar;
downloadSuccess = true;
- console.log(`[RestoreService] Successfully downloaded and packed backup to ${targetTar}`);
-
} catch (error) {
- console.error(`[RestoreService] Failed to download/pack backup:`, error);
throw error;
}
@@ -394,13 +375,12 @@ class RestoreService {
}
// Restore from packed tar file
- if (onProgress) onProgress('restoring', 'Restoring container...');
+ if (onProgress) await onProgress('restoring', 'Restoring container...');
try {
- console.log(`[RestoreService] Starting Restore from ${targetTar}`);
await this.restoreLocalBackup(server, ctId, downloadedPath, storageName);
} finally {
// Cleanup: delete downloaded folder and tar file
- if (onProgress) onProgress('cleanup', 'Cleaning up temporary files...');
+ if (onProgress) await onProgress('cleanup', 'Cleaning up temporary files...');
const cleanupCommand = `rm -rf "${targetFolder}" "${targetTar}" 2>&1 || true`;
sshService.executeCommand(
server,
@@ -422,20 +402,48 @@ class RestoreService {
onProgress?: (progress: RestoreProgress) => void
): Promise {
const progress: RestoreProgress[] = [];
+ const logPath = join(process.cwd(), 'restore.log');
- const addProgress = (step: string, message: string) => {
+ // Clear log file at start of restore
+ const clearLogFile = async () => {
+ try {
+ await writeFile(logPath, '', 'utf-8');
+ } catch (error) {
+ // Ignore log file errors
+ }
+ };
+
+ // Write progress to log file
+ const writeProgressToLog = async (message: string) => {
+ try {
+ const logLine = `${message}\n`;
+ await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
+ } catch (error) {
+ // Ignore log file errors
+ }
+ };
+
+ const addProgress = async (step: string, message: string) => {
const p = { step, message };
progress.push(p);
+
+ // Write to log file (just the message, without step prefix)
+ await writeProgressToLog(message);
+
+ // Call callback if provided
if (onProgress) {
onProgress(p);
}
};
try {
+ // Clear log file at start
+ await clearLogFile();
+
const db = getDatabase();
const sshService = getSSHExecutionService();
- console.log(`[RestoreService] Starting restore for backup ${backupId}, CT ${containerId}, server ${serverId}`);
+ await addProgress('starting', 'Starting restore...');
// Get backup details
const backup = await db.getBackupById(backupId);
@@ -443,8 +451,6 @@ class RestoreService {
throw new Error(`Backup with ID ${backupId} not found`);
}
- console.log(`[RestoreService] Backup found: ${backup.backup_name}, type: ${backup.storage_type}, path: ${backup.backup_path}`);
-
// Get server details
const server = await db.getServerById(serverId);
if (!server) {
@@ -452,10 +458,8 @@ class RestoreService {
}
// Get rootfs storage
- addProgress('reading_config', 'Reading container configuration...');
- console.log(`[RestoreService] Getting rootfs storage for CT ${containerId}`);
+ await addProgress('reading_config', 'Reading container configuration...');
const rootfsStorage = await this.getRootfsStorage(server, containerId);
- console.log(`[RestoreService] Rootfs storage: ${rootfsStorage || 'not found'}`);
if (!rootfsStorage) {
// Try to check if container exists, if not we can proceed without stopping/destroying
@@ -482,29 +486,24 @@ class RestoreService {
}
// Try to stop and destroy container - if it doesn't exist, continue anyway
- addProgress('stopping', 'Stopping container...');
+ await addProgress('stopping', 'Stopping container...');
try {
await this.stopContainer(server, containerId);
- console.log(`[RestoreService] Container ${containerId} stopped`);
} catch (error) {
- console.warn(`[RestoreService] Failed to stop container (may not exist or already stopped):`, error);
// Continue even if stop fails
}
// Try to destroy container - if it doesn't exist, continue anyway
- addProgress('destroying', 'Destroying container...');
+ await addProgress('destroying', 'Destroying container...');
try {
await this.destroyContainer(server, containerId);
- console.log(`[RestoreService] Container ${containerId} destroyed successfully`);
} catch (error) {
// Container might not exist, which is fine - continue with restore
- console.log(`[RestoreService] Container ${containerId} does not exist or destroy failed (continuing anyway):`, error);
- addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
+ await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
}
// Restore based on backup type
if (backup.storage_type === 'pbs') {
- console.log(`[RestoreService] Restoring from PBS backup`);
// Get storage info for PBS
const storageService = getStorageService();
const storages = await storageService.getStorages(server, false);
@@ -521,22 +520,17 @@ class RestoreService {
}
const snapshotPath = snapshotPathMatch[1];
- console.log(`[RestoreService] Snapshot path: ${snapshotPath}, Storage: ${rootfsStorage}`);
- await this.restorePBSBackup(server, storage, containerId, snapshotPath, rootfsStorage, (step, message) => {
- addProgress(step, message);
+ await this.restorePBSBackup(server, storage, containerId, snapshotPath, rootfsStorage, async (step, message) => {
+ await addProgress(step, message);
});
} else {
// Local or storage backup
- console.log(`[RestoreService] Restoring from ${backup.storage_type} backup: ${backup.backup_path}`);
- addProgress('restoring', 'Restoring container...');
+ await addProgress('restoring', 'Restoring container...');
await this.restoreLocalBackup(server, containerId, backup.backup_path, rootfsStorage);
- console.log(`[RestoreService] Local restore completed`);
}
- addProgress('complete', 'Restore completed successfully');
-
- console.log(`[RestoreService] Restore completed successfully for CT ${containerId}`);
+ await addProgress('complete', 'Restore completed successfully');
return {
success: true,
@@ -544,8 +538,7 @@ class RestoreService {
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
- console.error(`[RestoreService] Restore failed for CT ${containerId}:`, error);
- addProgress('error', `Error: ${errorMessage}`);
+ await addProgress('error', `Error: ${errorMessage}`);
return {
success: false,
From 5be88d361f628351b9c6d42a2fc1007aca96769f Mon Sep 17 00:00:00 2001
From: Michel Roegl-Brunner
Date: Tue, 18 Nov 2025 09:11:56 +0100
Subject: [PATCH 8/9] chore: cleanup debug output from backup modals
---
src/app/_components/BackupWarningModal.tsx | 2 ++
src/app/_components/StorageSelectionModal.tsx | 2 ++
2 files changed, 4 insertions(+)
diff --git a/src/app/_components/BackupWarningModal.tsx b/src/app/_components/BackupWarningModal.tsx
index c7b5fb0..d93f5c9 100644
--- a/src/app/_components/BackupWarningModal.tsx
+++ b/src/app/_components/BackupWarningModal.tsx
@@ -63,3 +63,5 @@ export function BackupWarningModal({
);
}
+
+
diff --git a/src/app/_components/StorageSelectionModal.tsx b/src/app/_components/StorageSelectionModal.tsx
index ee998ce..1c24671 100644
--- a/src/app/_components/StorageSelectionModal.tsx
+++ b/src/app/_components/StorageSelectionModal.tsx
@@ -164,3 +164,5 @@ export function StorageSelectionModal({
);
}
+
+
From 3a8088ded6e6dc8e8ef7ed39238a1c651bcef8da Mon Sep 17 00:00:00 2001
From: Michel Roegl-Brunner
Date: Tue, 18 Nov 2025 09:16:31 +0100
Subject: [PATCH 9/9] chore: add missing migration for backups and
pbs_storage_credentials tables
---
.../migration.sql | 41 +++++++++++++++++++
1 file changed, 41 insertions(+)
create mode 100644 prisma/migrations/20251118091618_add_backups_and_pbs_credentials/migration.sql
diff --git a/prisma/migrations/20251118091618_add_backups_and_pbs_credentials/migration.sql b/prisma/migrations/20251118091618_add_backups_and_pbs_credentials/migration.sql
new file mode 100644
index 0000000..a65f6da
--- /dev/null
+++ b/prisma/migrations/20251118091618_add_backups_and_pbs_credentials/migration.sql
@@ -0,0 +1,41 @@
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "backups" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "container_id" TEXT NOT NULL,
+ "server_id" INTEGER NOT NULL,
+ "hostname" TEXT NOT NULL,
+ "backup_name" TEXT NOT NULL,
+ "backup_path" TEXT NOT NULL,
+ "size" BIGINT,
+ "created_at" DATETIME,
+ "storage_name" TEXT NOT NULL,
+ "storage_type" TEXT NOT NULL,
+ "discovered_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "backups_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "pbs_storage_credentials" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "server_id" INTEGER NOT NULL,
+ "storage_name" TEXT NOT NULL,
+ "pbs_ip" TEXT NOT NULL,
+ "pbs_datastore" TEXT NOT NULL,
+ "pbs_password" TEXT NOT NULL,
+ "pbs_fingerprint" TEXT NOT NULL,
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" DATETIME NOT NULL,
+ CONSTRAINT "pbs_storage_credentials_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "backups_container_id_idx" ON "backups"("container_id");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "backups_server_id_idx" ON "backups"("server_id");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "pbs_storage_credentials_server_id_idx" ON "pbs_storage_credentials"("server_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX IF NOT EXISTS "pbs_storage_credentials_server_id_storage_name_key" ON "pbs_storage_credentials"("server_id", "storage_name");