Initial for Backup function
This commit is contained in:
196
server.js
196
server.js
@@ -276,13 +276,15 @@ class ScriptExecutionHandler {
|
|||||||
* @param {WebSocketMessage} message
|
* @param {WebSocketMessage} message
|
||||||
*/
|
*/
|
||||||
async handleMessage(ws, 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) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
if (scriptPath && executionId) {
|
if (scriptPath && executionId) {
|
||||||
if (isUpdate && containerId) {
|
if (isBackup && containerId && storage) {
|
||||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
|
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) {
|
} else if (isShell && containerId) {
|
||||||
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
||||||
} else {
|
} 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)
|
* Start update execution (pct enter + update command)
|
||||||
* @param {ExtendedWebSocket} ws
|
* @param {ExtendedWebSocket} ws
|
||||||
@@ -667,11 +778,86 @@ class ScriptExecutionHandler {
|
|||||||
* @param {string} executionId
|
* @param {string} executionId
|
||||||
* @param {string} mode
|
* @param {string} mode
|
||||||
* @param {ServerInfo|null} server
|
* @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 {
|
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, {
|
this.sendMessage(ws, {
|
||||||
type: 'start',
|
type: 'start',
|
||||||
data: `Starting update for container ${containerId}...`,
|
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 { ErrorModal } from './ErrorModal';
|
||||||
import { LoadingModal } from './LoadingModal';
|
import { LoadingModal } from './LoadingModal';
|
||||||
import { LXCSettingsModal } from './LXCSettingsModal';
|
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 { getContrastColor } from '../../lib/colorUtils';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -50,8 +53,14 @@ export function InstalledScriptsTab() {
|
|||||||
const [serverFilter, setServerFilter] = useState<string>('all');
|
const [serverFilter, setServerFilter] = useState<string>('all');
|
||||||
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
|
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
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 [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 [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 [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);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
@@ -244,22 +253,54 @@ export function InstalledScriptsTab() {
|
|||||||
void refetchScripts();
|
void refetchScripts();
|
||||||
setAutoDetectStatus({
|
setAutoDetectStatus({
|
||||||
type: 'success',
|
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);
|
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('❌ Auto-detect Web UI error:', error);
|
console.error('❌ Auto-detect WebUI error:', error);
|
||||||
setAutoDetectStatus({
|
setAutoDetectStatus({
|
||||||
type: 'error',
|
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: '' }), 8000);
|
||||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Container control mutations
|
||||||
// Note: getStatusMutation removed - using direct API calls instead
|
// 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.`,
|
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',
|
variant: 'danger',
|
||||||
confirmText: script.container_id,
|
confirmText: script.container_id,
|
||||||
confirmButtonText: 'Update Script',
|
confirmButtonText: 'Continue',
|
||||||
onConfirm: () => {
|
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);
|
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 = () => {
|
const handleCloseUpdateTerminal = () => {
|
||||||
setUpdatingScript(null);
|
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) => {
|
const handleOpenShell = (script: InstalledScript) => {
|
||||||
if (!script.container_id) {
|
if (!script.container_id) {
|
||||||
setErrorModal({
|
setErrorModal({
|
||||||
@@ -887,12 +1039,15 @@ export function InstalledScriptsTab() {
|
|||||||
{updatingScript && (
|
{updatingScript && (
|
||||||
<div className="mb-8" data-terminal="update">
|
<div className="mb-8" data-terminal="update">
|
||||||
<Terminal
|
<Terminal
|
||||||
scriptPath={`update-${updatingScript.containerId}`}
|
scriptPath={updatingScript.isBackupOnly ? `backup-${updatingScript.containerId}` : `update-${updatingScript.containerId}`}
|
||||||
onClose={handleCloseUpdateTerminal}
|
onClose={handleCloseUpdateTerminal}
|
||||||
mode={updatingScript.server ? 'ssh' : 'local'}
|
mode={updatingScript.server ? 'ssh' : 'local'}
|
||||||
server={updatingScript.server}
|
server={updatingScript.server}
|
||||||
isUpdate={true}
|
isUpdate={!updatingScript.isBackupOnly}
|
||||||
|
isBackup={updatingScript.isBackupOnly}
|
||||||
containerId={updatingScript.containerId}
|
containerId={updatingScript.containerId}
|
||||||
|
storage={updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined}
|
||||||
|
backupStorage={!updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1252,6 +1407,7 @@ export function InstalledScriptsTab() {
|
|||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
onCancel={handleCancelEdit}
|
onCancel={handleCancelEdit}
|
||||||
onUpdate={() => handleUpdateScript(script)}
|
onUpdate={() => handleUpdateScript(script)}
|
||||||
|
onBackup={() => handleBackupScript(script)}
|
||||||
onShell={() => handleOpenShell(script)}
|
onShell={() => handleOpenShell(script)}
|
||||||
onDelete={() => handleDeleteScript(Number(script.id))}
|
onDelete={() => handleDeleteScript(Number(script.id))}
|
||||||
isUpdating={updateScriptMutation.isPending}
|
isUpdating={updateScriptMutation.isPending}
|
||||||
@@ -1530,6 +1686,15 @@ export function InstalledScriptsTab() {
|
|||||||
Update
|
Update
|
||||||
</DropdownMenuItem>
|
</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' && (
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleOpenShell(script)}
|
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 */}
|
{/* LXC Settings Modal */}
|
||||||
<LXCSettingsModal
|
<LXCSettingsModal
|
||||||
isOpen={lxcSettingsModal.isOpen}
|
isOpen={lxcSettingsModal.isOpen}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface ScriptInstallationCardProps {
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
|
onBackup?: () => void;
|
||||||
onShell: () => void;
|
onShell: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
@@ -68,6 +69,7 @@ export function ScriptInstallationCard({
|
|||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onBackup,
|
||||||
onShell,
|
onShell,
|
||||||
onDelete,
|
onDelete,
|
||||||
isUpdating,
|
isUpdating,
|
||||||
@@ -307,6 +309,15 @@ export function ScriptInstallationCard({
|
|||||||
Update
|
Update
|
||||||
</DropdownMenuItem>
|
</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' && (
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={onShell}
|
onClick={onShell}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { ServerForm } from './ServerForm';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ConfirmationModal } from './ConfirmationModal';
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
import { PublicKeyModal } from './PublicKeyModal';
|
import { PublicKeyModal } from './PublicKeyModal';
|
||||||
import { Key } from 'lucide-react';
|
import { ServerStoragesModal } from './ServerStoragesModal';
|
||||||
|
import { Key, Database } from 'lucide-react';
|
||||||
|
|
||||||
interface ServerListProps {
|
interface ServerListProps {
|
||||||
servers: Server[];
|
servers: Server[];
|
||||||
@@ -32,6 +33,8 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
serverName: string;
|
serverName: string;
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showStoragesModal, setShowStoragesModal] = useState(false);
|
||||||
|
const [selectedServerForStorages, setSelectedServerForStorages] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
|
||||||
const handleEdit = (server: Server) => {
|
const handleEdit = (server: Server) => {
|
||||||
setEditingId(server.id);
|
setEditingId(server.id);
|
||||||
@@ -251,6 +254,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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">
|
<div className="flex space-x-2">
|
||||||
{/* View Public Key button - only show for generated keys */}
|
{/* View Public Key button - only show for generated keys */}
|
||||||
{server.key_generated === true && (
|
{server.key_generated === true && (
|
||||||
@@ -324,6 +340,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
serverIp={publicKeyData.serverIp}
|
serverIp={publicKeyData.serverIp}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Server Storages Modal */}
|
||||||
|
{selectedServerForStorages && (
|
||||||
|
<ServerStoragesModal
|
||||||
|
isOpen={showStoragesModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowStoragesModal(false);
|
||||||
|
setSelectedServerForStorages(null);
|
||||||
|
}}
|
||||||
|
serverId={selectedServerForStorages.id}
|
||||||
|
serverName={selectedServerForStorages.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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;
|
server?: any;
|
||||||
isUpdate?: boolean;
|
isUpdate?: boolean;
|
||||||
isShell?: boolean;
|
isShell?: boolean;
|
||||||
|
isBackup?: boolean;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
|
storage?: string;
|
||||||
|
backupStorage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TerminalMessage {
|
interface TerminalMessage {
|
||||||
@@ -21,7 +24,7 @@ interface TerminalMessage {
|
|||||||
timestamp: number;
|
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 [isConnected, setIsConnected] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
@@ -334,7 +337,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
server,
|
server,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
isShell,
|
isShell,
|
||||||
containerId
|
isBackup,
|
||||||
|
containerId,
|
||||||
|
storage,
|
||||||
|
backupStorage
|
||||||
};
|
};
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
|||||||
import { getDatabase } from "~/server/database-prisma";
|
import { getDatabase } from "~/server/database-prisma";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import type { Server } from "~/types/server";
|
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
|
// Helper function to parse raw LXC config into structured data
|
||||||
function parseRawConfig(rawConfig: string): any {
|
function parseRawConfig(rawConfig: string): any {
|
||||||
@@ -2038,5 +2040,113 @@ EOFCONFIG`;
|
|||||||
.getLXCConfig({ scriptId: input.scriptId, forceSync: true });
|
.getLXCConfig({ scriptId: input.scriptId, forceSync: true });
|
||||||
|
|
||||||
return result;
|
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