Initial for Backup function
This commit is contained in:
196
server.js
196
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<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
|
||||
// Send start message for update
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting update for container ${containerId}...`,
|
||||
|
||||
65
src/app/_components/BackupWarningModal.tsx
Normal file
65
src/app/_components/BackupWarningModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
177
src/app/_components/ServerStoragesModal.tsx
Normal file
177
src/app/_components/ServerStoragesModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
166
src/app/_components/StorageSelectionModal.tsx
Normal file
166
src/app/_components/StorageSelectionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
197
src/server/services/storageService.ts
Normal file
197
src/server/services/storageService.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user