Initial for Backup function

This commit is contained in:
Michel Roegl-Brunner
2025-11-14 08:44:33 +01:00
parent dab2da4b70
commit 4ea49be97d
10 changed files with 1224 additions and 39 deletions

196
server.js
View File

@@ -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()
});
// Send start message
// 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<void>((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 for update
this.sendMessage(ws, {
type: 'start',
data: `Starting update for container ${containerId}...`,

View File

@@ -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 (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-warning" />
<h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-sm text-muted-foreground mb-6">
The backup failed, but you can still proceed with the update if you wish.
<br /><br />
<strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update.
</p>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3">
<Button
onClick={onClose}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={onProceed}
variant="default"
size="default"
className="w-full sm:w-auto bg-warning hover:bg-warning/90"
>
Proceed Anyway
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<string>('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<InstalledScript | null>(null);
const [backupStorages, setBackupStorages] = useState<Storage[]>([]);
const [isLoadingStorages, setIsLoadingStorages] = useState(false);
const [showBackupWarning, setShowBackupWarning] = useState(false);
const [editingScriptId, setEditingScriptId] = useState<number | null>(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 && (
<div className="mb-8" data-terminal="update">
<Terminal
scriptPath={`update-${updatingScript.containerId}`}
scriptPath={updatingScript.isBackupOnly ? `backup-${updatingScript.containerId}` : `update-${updatingScript.containerId}`}
onClose={handleCloseUpdateTerminal}
mode={updatingScript.server ? 'ssh' : 'local'}
server={updatingScript.server}
isUpdate={true}
isUpdate={!updatingScript.isBackupOnly}
isBackup={updatingScript.isBackupOnly}
containerId={updatingScript.containerId}
storage={updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined}
backupStorage={!updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined}
/>
</div>
)}
@@ -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
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<DropdownMenuItem
onClick={() => handleBackupScript(script)}
disabled={containerStatuses.get(script.id) === 'stopped'}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Backup
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<DropdownMenuItem
onClick={() => handleOpenShell(script)}
@@ -1656,6 +1821,79 @@ export function InstalledScriptsTab() {
/>
)}
{/* Backup Prompt Modal */}
{showBackupPrompt && (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3">
<svg className="h-8 w-8 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<h2 className="text-2xl font-bold text-card-foreground">Backup Before Update?</h2>
</div>
</div>
<div className="p-6">
<p className="text-sm text-muted-foreground mb-6">
Would you like to create a backup before updating the container?
</p>
<div className="flex flex-col sm:flex-row justify-end gap-3">
<Button
onClick={() => {
setShowBackupPrompt(false);
handleBackupPromptResponse(false);
}}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
No, Update Without Backup
</Button>
<Button
onClick={() => handleBackupPromptResponse(true)}
variant="default"
size="default"
className="w-full sm:w-auto"
>
Yes, Backup First
</Button>
</div>
</div>
</div>
</div>
)}
{/* Storage Selection Modal */}
<StorageSelectionModal
isOpen={showStorageSelection}
onClose={() => {
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 */}
<BackupWarningModal
isOpen={showBackupWarning}
onClose={() => setShowBackupWarning(false)}
onProceed={() => {
setShowBackupWarning(false);
// Proceed with update even though backup failed
if (pendingUpdateScript) {
proceedWithUpdate(null);
}
}}
/>
{/* LXC Settings Modal */}
<LXCSettingsModal
isOpen={lxcSettingsModal.isOpen}

View File

@@ -44,6 +44,7 @@ interface ScriptInstallationCardProps {
onSave: () => 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
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && onBackup && (
<DropdownMenuItem
onClick={onBackup}
disabled={containerStatus === 'stopped'}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Backup
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<DropdownMenuItem
onClick={onShell}

View File

@@ -6,7 +6,8 @@ import { ServerForm } from './ServerForm';
import { Button } from './ui/button';
import { ConfirmationModal } from './ConfirmationModal';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
import { ServerStoragesModal } from './ServerStoragesModal';
import { Key, Database } from 'lucide-react';
interface ServerListProps {
servers: Server[];
@@ -32,6 +33,8 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
serverName: string;
serverIp: string;
} | null>(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) {
</>
)}
</Button>
<Button
onClick={() => {
setSelectedServerForStorages({ id: server.id, name: server.name });
setShowStoragesModal(true);
}}
variant="outline"
size="sm"
className="w-full sm:w-auto border-info/20 text-info bg-info/10 hover:bg-info/20"
>
<Database className="w-4 h-4 mr-1" />
<span className="hidden sm:inline">View Storages</span>
<span className="sm:hidden">Storages</span>
</Button>
<div className="flex space-x-2">
{/* 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 && (
<ServerStoragesModal
isOpen={showStoragesModal}
onClose={() => {
setShowStoragesModal(false);
setSelectedServerForStorages(null);
}}
serverId={selectedServerForStorages.id}
serverName={selectedServerForStorages.name}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<Database className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">
Storages for {serverName}
</h2>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleRefresh}
variant="outline"
size="sm"
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
<p className="text-muted-foreground">Loading storages...</p>
</div>
) : !data?.success ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">Failed to load storages</p>
<p className="text-sm text-muted-foreground mb-4">
{data?.error ?? 'Unknown error occurred'}
</p>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
) : storages.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">No storages found</p>
<p className="text-sm text-muted-foreground">
Make sure your server has storages configured.
</p>
</div>
) : (
<>
{data.cached && (
<div className="mb-4 p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
Showing cached data. Click Refresh to fetch latest from server.
</div>
)}
<div className="space-y-3">
{storages.map((storage) => {
const isBackupCapable = storage.supportsBackup;
return (
<div
key={storage.name}
className={`p-4 border rounded-lg ${
isBackupCapable
? 'border-success/50 bg-success/5'
: 'border-border bg-card'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-medium text-foreground">{storage.name}</h3>
{isBackupCapable && (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
<CheckCircle className="h-3 w-3" />
Backup
</span>
)}
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
{storage.type}
</span>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>
<span className="font-medium">Content:</span> {storage.content.join(', ')}
</div>
{storage.nodes && storage.nodes.length > 0 && (
<div>
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
</div>
)}
{Object.entries(storage)
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
.map(([key, value]) => (
<div key={key}>
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
</div>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
{backupStorages.length > 0 && (
<div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
<p className="text-sm text-success font-medium">
{backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<Storage | null>(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 (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<Database className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">Select Backup Storage</h2>
</div>
<Button
onClick={handleClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Content */}
<div className="p-6">
{isLoading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
<p className="text-muted-foreground">Loading storages...</p>
</div>
) : backupStorages.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">No backup-capable storages found</p>
<p className="text-sm text-muted-foreground mb-4">
Make sure your server has storages configured with backup content type.
</p>
<Button onClick={onRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Storages
</Button>
</div>
) : (
<>
<p className="text-sm text-muted-foreground mb-4">
Select a storage to use for the backup. Only storages that support backups are shown.
</p>
{/* Storage List */}
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
{backupStorages.map((storage) => (
<div
key={storage.name}
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{storage.name}</h3>
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
Backup
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
{storage.type}
</span>
</div>
<div className="text-sm text-muted-foreground">
<span>Content: {storage.content.join(', ')}</span>
{storage.nodes && storage.nodes.length > 0 && (
<span className="ml-2"> Nodes: {storage.nodes.join(', ')}</span>
)}
</div>
</div>
{selectedStorage?.name === storage.name && (
<CheckCircle className="h-5 w-5 text-primary flex-shrink-0 ml-2" />
)}
</div>
</div>
))}
</div>
{/* Refresh Button */}
<div className="flex justify-end mb-4">
<Button onClick={onRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Fetch Storages
</Button>
</div>
</>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3">
<Button
onClick={handleClose}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleSelect}
disabled={!selectedStorage}
variant="default"
size="default"
className="w-full sm:w-auto"
>
Select Storage
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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));
}

View File

@@ -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
};
}
})
});

View File

@@ -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<number, CachedStorageData> = 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<Storage> | 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>): 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<Storage[]> {
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<void>((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<Storage[]> {
return this.fetchStoragesFromServer(server, forceRefresh);
}
/**
* Get only backup-capable storages
*/
async getBackupStorages(server: Server, forceRefresh = false): Promise<Storage[]> {
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;
}