feat: implement disk resize with improved modal flow and error handling (#177)

- Add automatic disk resizing when changing LXC disk size in settings
- Implement separate modal flow: confirmation -> loading -> result -> settings
- Add proper error detection for pct resize command (check both exit code and output)
- Add LVM fallback resize method when pct resize fails
- Implement configuration rollback on resize failure
- Update modal styling to use semantic color classes for proper dark mode support
- Add data refresh after result modal close to show updated values
- Remove success/error banners from settings modal for cleaner UI
This commit is contained in:
Michel Roegl-Brunner
2025-10-17 13:34:37 +02:00
committed by GitHub
parent c1b478ed51
commit 08b7eecdfe
2 changed files with 490 additions and 34 deletions

View File

@@ -43,10 +43,13 @@ interface LXCSettingsModalProps {
export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSettingsModalProps) {
const [activeTab, setActiveTab] = useState<string>('common');
const [showConfirmation, setShowConfirmation] = useState(false);
const [showResultModal, setShowResultModal] = useState(false);
const [resultType, setResultType] = useState<'success' | 'error' | null>(null);
const [resultMessage, setResultMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState(false);
const [forceSync] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState<any>({
arch: '',
@@ -76,27 +79,41 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting
});
// tRPC hooks
const { data: configData, isLoading } = api.installedScripts.getLXCConfig.useQuery(
const { data: configData, isLoading, refetch } = api.installedScripts.getLXCConfig.useQuery(
{ scriptId: script?.id ?? 0, forceSync },
{ enabled: !!script && isOpen }
);
const saveMutation = api.installedScripts.saveLXCConfig.useMutation({
onSuccess: () => {
setSuccessMessage('LXC configuration saved successfully');
setHasChanges(false);
onSuccess: (data) => {
console.log('Save mutation success data:', data);
setIsSaving(false);
setShowConfirmation(false);
onSave();
if (data.success) {
setResultType('success');
setResultMessage(data.message ?? 'LXC configuration saved successfully');
setHasChanges(false);
} else {
console.log('Backend returned error:', data.error);
setResultType('error');
setResultMessage(data.error ?? 'Failed to save configuration');
}
setShowResultModal(true);
},
onError: (err) => {
setError(`Failed to save configuration: ${err.message}`);
console.log('Save mutation error:', err);
setIsSaving(false);
setShowConfirmation(false);
setResultType('error');
setResultMessage(`Failed to save configuration: ${err.message}`);
setShowResultModal(true);
}
});
const syncMutation = api.installedScripts.syncLXCConfig.useMutation({
onSuccess: (result) => {
populateFormData(result);
setSuccessMessage('Configuration synced from server successfully');
setHasChanges(false);
},
onError: (err) => {
@@ -158,13 +175,61 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting
syncMutation.mutate({ scriptId: script.id });
};
const validateForm = () => {
// Check required fields
if (!formData.arch?.trim()) {
setError('Architecture is required');
return false;
}
if (!formData.cores || formData.cores < 1) {
setError('Cores must be at least 1');
return false;
}
if (!formData.memory || formData.memory < 128) {
setError('Memory must be at least 128 MB');
return false;
}
if (!formData.hostname?.trim()) {
setError('Hostname is required');
return false;
}
if (!formData.ostype?.trim()) {
setError('OS Type is required');
return false;
}
if (!formData.rootfs_storage?.trim()) {
setError('Root filesystem storage is required');
return false;
}
// Check if trying to decrease disk size
const currentSize = configData?.config?.rootfs_size ?? '0G';
const newSize = formData.rootfs_size ?? '0G';
const currentSizeGB = parseFloat(String(currentSize));
const newSizeGB = parseFloat(String(newSize));
if (newSizeGB < currentSizeGB) {
setError('Disk size cannot be decreased. Only increases are allowed for safety.');
return false;
}
return true;
};
const handleSave = () => {
setShowConfirmation(true);
setError(null);
// Validate form - only show confirmation modal if no errors
if (validateForm()) {
setShowConfirmation(true);
}
};
const handleConfirmSave = () => {
if (!script) return;
setError(null);
setIsSaving(true);
setShowConfirmation(false);
saveMutation.mutate({
scriptId: script.id,
@@ -179,6 +244,14 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting
});
};
const handleResultModalClose = () => {
setShowResultModal(false);
setResultType(null);
setResultMessage(null);
// Refresh the data to show updated values
void refetch();
};
if (!isOpen || !script) return null;
return (
@@ -229,23 +302,6 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting
</div>
)}
{/* Success Message */}
{successMessage && (
<div className="bg-green-50 dark:bg-green-950/20 border-b border-green-200 dark:border-green-800 p-4">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-green-800 dark:text-green-200">{successMessage}</p>
</div>
<button
onClick={() => setSuccessMessage(null)}
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400"
>
</button>
</div>
</div>
)}
{/* Error Message */}
{error && (
@@ -485,13 +541,19 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting
/>
</div>
<div className="space-y-2">
<label htmlFor="rootfs_size" className="block text-sm font-medium text-foreground">Size</label>
<label htmlFor="rootfs_size" className="block text-sm font-medium text-foreground">
Size
<span className="text-xs text-muted-foreground ml-2">(can only be increased)</span>
</label>
<Input
id="rootfs_size"
value={formData.rootfs_size}
onChange={(e) => handleInputChange('rootfs_size', e.target.value)}
placeholder="4G"
/>
<p className="text-xs text-muted-foreground">
Disk size can only be increased for safety. Format: 4G, 8G, 16G, etc.
</p>
</div>
</div>
</div>
@@ -590,10 +652,10 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting
</Button>
<Button
onClick={handleSave}
disabled={saveMutation.isPending || !hasChanges}
disabled={isSaving || saveMutation.isPending || !hasChanges}
variant="default"
>
{saveMutation.isPending ? 'Saving...' : 'Save Configuration'}
{isSaving ? 'Saving & Resizing...' : saveMutation.isPending ? 'Saving...' : 'Save Configuration'}
</Button>
</div>
</div>
@@ -608,17 +670,55 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSetting
}}
onConfirm={handleConfirmSave}
title="Confirm LXC Configuration Changes"
message="Modifying LXC configuration can break your container and may require manual recovery. Ensure you understand these changes before proceeding. The container may need to be restarted for changes to take effect."
message={`Modifying LXC configuration can break your container and may require manual recovery. Ensure you understand these changes before proceeding.${
formData.rootfs_size && configData?.config?.rootfs_size &&
parseFloat(String(formData.rootfs_size)) > parseFloat(String(configData.config.rootfs_size ?? '0'))
? `\n\n⚠ DISK RESIZE DETECTED: The disk size will be increased from ${configData.config.rootfs_size} to ${formData.rootfs_size}. This operation will automatically resize the underlying storage and filesystem.`
: ''
}\n\nThe container may need to be restarted for changes to take effect.`}
variant="danger"
confirmText={script.container_id ?? ''}
confirmButtonText="Save Configuration"
confirmButtonText={formData.rootfs_size && configData?.config?.rootfs_size &&
parseFloat(String(formData.rootfs_size)) > parseFloat(String(configData.config.rootfs_size ?? '0'))
? "Save & Resize Disk" : "Save Configuration"}
/>
{/* Loading Modal */}
<LoadingModal
isOpen={isLoading}
action="Loading LXC configuration..."
isOpen={isLoading || isSaving}
action={isSaving ? "Saving configuration and resizing disk..." : "Loading LXC configuration..."}
/>
{/* Result Modal */}
{showResultModal && resultType && resultMessage && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-card text-card-foreground rounded-lg shadow-xl max-w-md w-full mx-4 border border-border">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
{resultType === 'success' ? (
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-500" />
) : (
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-500" />
)}
<h3 className="text-lg font-semibold text-card-foreground">
{resultType === 'success' ? 'Success' : 'Error'}
</h3>
</div>
<p className="text-muted-foreground mb-6">
{resultMessage}
</p>
<div className="flex justify-end">
<button
onClick={handleResultModalClose}
className="px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -142,6 +142,246 @@ function calculateConfigHash(rawConfig: string): string {
return createHash('md5').update(rawConfig).digest('hex');
}
// Helper function to parse rootfs_storage and extract storage pool and disk identifier
function parseRootfsStorage(rootfs_storage: string): { storagePool: string; diskId: string } | null {
// Format: "PROX2-STORAGE2:vm-113-disk-0"
const regex = /^([^:]+):(.+)$/;
const match = regex.exec(rootfs_storage);
if (!match?.[1] || !match?.[2]) return null;
return {
storagePool: match[1],
diskId: match[2]
};
}
// Helper function to extract size in GB from size string
function extractSizeInGB(sizeString: string): number {
if (!sizeString) return 0;
const regex = /^(\d+(?:\.\d+)?)\s*([GMK]?)$/i;
const match = regex.exec(sizeString);
if (!match?.[1]) return 0;
const value = parseFloat(match[1]);
const unit = (match[2] ?? '').toUpperCase();
switch (unit) {
case 'T': return value * 1024;
case 'G': return value;
case 'M': return value / 1024;
case 'K': return value / (1024 * 1024);
case '': return value; // Assume GB if no unit
default: return 0;
}
}
// Helper function to resize disk
async function resizeDisk(
server: Server,
containerId: string,
storageInfo: { storagePool: string; diskId: string },
oldSizeGB: number,
newSizeGB: number
): Promise<{ success: boolean; message: string; error?: string }> {
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = new SSHExecutionService();
try {
// First, try using pct resize (works for most storage types)
const pctCommand = `pct resize ${containerId} rootfs ${newSizeGB}G`;
return new Promise((resolve) => {
let errorOutput = '';
let dataOutput = '';
console.log(`Executing pct resize command: ${pctCommand}`);
void sshExecutionService.executeCommand(
server,
pctCommand,
(data: string) => {
dataOutput += data;
console.log('pct resize data:', data);
},
(error: string) => {
errorOutput += error;
console.log('pct resize error:', error);
},
(exitCode: number) => {
console.log(`pct resize exit code: ${exitCode}`);
console.log(`pct resize error output: "${errorOutput}"`);
console.log(`pct resize data output: "${dataOutput}"`);
// Check for error messages in both stderr and stdout
const hasError = errorOutput.trim() || dataOutput.toLowerCase().includes('error') || dataOutput.toLowerCase().includes('insufficient');
// Check both exit code and error output for failure
if (exitCode === 0 && !hasError) {
resolve({
success: true,
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using pct resize`
});
} else {
// If pct resize fails (either non-zero exit code or error output), try LVM-specific commands
const errorMessage = errorOutput.trim() || dataOutput.trim();
const combinedError = errorMessage ? `pct resize error: ${errorMessage}` : `pct resize failed with exit code ${exitCode}`;
void tryLVMResize(server, containerId, storageInfo, newSizeGB, oldSizeGB, resolve, combinedError);
}
}
);
});
} catch (error) {
return {
success: false,
message: 'Resize failed',
error: error instanceof Error ? error.message : 'Unknown error during resize'
};
}
}
// Helper function to try LVM-specific resize
async function tryLVMResize(
server: Server,
containerId: string,
storageInfo: { storagePool: string; diskId: string },
newSizeGB: number,
oldSizeGB: number,
resolve: (result: { success: boolean; message: string; error?: string }) => void,
previousError?: string
) {
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = new SSHExecutionService();
// Try LVM resize commands
const lvPath = `/dev/${storageInfo.storagePool}/${storageInfo.diskId}`;
const lvresizeCommand = `lvresize -L ${newSizeGB}G ${lvPath}`;
void sshExecutionService.executeCommand(
server,
lvresizeCommand,
(_data: string) => {
// Now resize the filesystem
const resize2fsCommand = `resize2fs ${lvPath}`;
void sshExecutionService.executeCommand(
server,
resize2fsCommand,
(_fsData: string) => {
resolve({
success: true,
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands`
});
},
(fsError: string) => {
// Try xfs_growfs as fallback
const xfsCommand = `xfs_growfs ${lvPath}`;
void sshExecutionService.executeCommand(
server,
xfsCommand,
(_xfsData: string) => {
resolve({
success: true,
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands`
});
},
(xfsError: string) => {
resolve({
success: false,
message: 'Filesystem resize failed',
error: `LVM resize succeeded but filesystem resize failed: ${fsError}, XFS fallback also failed: ${xfsError}`
});
},
(xfsExitCode: number) => {
if (xfsExitCode === 0) {
resolve({
success: true,
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands`
});
} else {
resolve({
success: false,
message: 'Filesystem resize failed',
error: `LVM resize succeeded but filesystem resize failed: ${fsError}, XFS fallback also failed with exit code ${xfsExitCode}`
});
}
}
);
},
(fsExitCode: number) => {
if (fsExitCode === 0) {
resolve({
success: true,
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands`
});
} else {
// Try xfs_growfs as fallback
const xfsCommand = `xfs_growfs ${lvPath}`;
void sshExecutionService.executeCommand(
server,
xfsCommand,
(_xfsData: string) => {
resolve({
success: true,
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands`
});
},
(xfsError: string) => {
resolve({
success: false,
message: 'Filesystem resize failed',
error: `LVM resize succeeded but filesystem resize failed with exit code ${fsExitCode}, XFS fallback also failed: ${xfsError}`
});
},
(xfsExitCode: number) => {
if (xfsExitCode === 0) {
resolve({
success: true,
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands`
});
} else {
resolve({
success: false,
message: 'Filesystem resize failed',
error: `LVM resize succeeded but filesystem resize failed with exit code ${fsExitCode}, XFS fallback also failed with exit code ${xfsExitCode}`
});
}
}
);
}
}
);
},
(error: string) => {
const combinedError = previousError ? `${previousError} LVM error: ${error}` : `LVM resize failed: ${error}`;
resolve({
success: false,
message: 'Resize failed',
error: `Both pct resize and LVM resize failed. ${combinedError}`
});
},
(exitCode: number) => {
if (exitCode === 0) {
// This shouldn't happen as we're in the error callback, but handle it
resolve({
success: true,
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands`
});
} else {
const combinedError = previousError ? `${previousError} LVM command failed with exit code ${exitCode}` : `LVM command failed with exit code ${exitCode}`;
resolve({
success: false,
message: 'Resize failed',
error: `Both pct resize and LVM resize failed. ${combinedError}`
});
}
}
);
}
export const installedScriptsRouter = createTRPCRouter({
// Get all installed scripts
@@ -1536,6 +1776,19 @@ export const installedScriptsRouter = createTRPCRouter({
};
}
// Get current config for comparison
const currentConfig = await db.getLXCConfigByScriptId(input.scriptId);
const oldSizeGB = currentConfig ? extractSizeInGB(String(currentConfig.rootfs_size ?? '0G')) : 0;
const newSizeGB = extractSizeInGB(String(input.config.rootfs_size ?? '0G'));
// Validate size change - only allow increases
if (newSizeGB < oldSizeGB) {
return {
success: false,
error: `Disk size cannot be decreased. Current size: ${oldSizeGB}G, requested size: ${newSizeGB}G. Only increases are allowed for safety.`
};
}
// Write config file using heredoc for safe escaping
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
const writeCommand = `cat > "${configPath}" << 'EOFCONFIG'
@@ -1562,6 +1815,104 @@ EOFCONFIG`;
);
});
// Check if disk size increased and needs resizing
let resizeResult: { success: boolean; message: string; error?: string } | null = null;
if (newSizeGB > oldSizeGB) {
// Parse storage information
const storageInfo = parseRootfsStorage(String(input.config.rootfs_storage));
if (!storageInfo) {
// Rollback config file
const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG'
${reconstructConfig(currentConfig ?? {})}
EOFCONFIG`;
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
rollbackCommand,
() => resolve(),
(error: string) => reject(new Error(error)),
(exitCode: number) => {
if (exitCode === 0) resolve();
else reject(new Error(`Rollback failed with exit code ${exitCode}`));
}
);
});
return {
success: false,
error: 'Invalid rootfs_storage format. Configuration rolled back.'
};
}
// Attempt disk resize
try {
console.log(`Attempting to resize disk from ${oldSizeGB}G to ${newSizeGB}G for container ${script.container_id}`);
resizeResult = await resizeDisk(server as Server, script.container_id, storageInfo, oldSizeGB, newSizeGB);
console.log('Resize result:', resizeResult);
if (!resizeResult.success) {
console.log('Resize failed, attempting rollback...');
// Rollback config file on resize failure
const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG'
${reconstructConfig(currentConfig ?? {})}
EOFCONFIG`;
try {
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
rollbackCommand,
() => resolve(),
(error: string) => reject(new Error(error)),
(exitCode: number) => {
if (exitCode === 0) resolve();
else reject(new Error(`Rollback failed with exit code ${exitCode}`));
}
);
});
console.log('Rollback successful');
} catch (rollbackError) {
console.error('Rollback failed:', rollbackError);
}
return {
success: false,
error: `Configuration rolled back. Disk resize failed: ${resizeResult.error}`
};
}
} catch (error) {
console.error('Resize operation threw error:', error);
// Rollback config file on resize error
const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG'
${reconstructConfig(currentConfig ?? {})}
EOFCONFIG`;
try {
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
rollbackCommand,
() => resolve(),
(error: string) => reject(new Error(error)),
(exitCode: number) => {
if (exitCode === 0) resolve();
else reject(new Error(`Rollback failed with exit code ${exitCode}`));
}
);
});
console.log('Rollback successful after error');
} catch (rollbackError) {
console.error('Rollback failed after error:', rollbackError);
}
return {
success: false,
error: `Configuration rolled back. Disk resize error: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
// Update database cache
const configData = {
...input.config,
@@ -1571,9 +1922,14 @@ EOFCONFIG`;
await db.updateLXCConfig(input.scriptId, configData);
// Return success message with resize info if applicable
const message = resizeResult
? `LXC configuration saved successfully. ${resizeResult.message}`
: 'LXC configuration saved successfully';
return {
success: true,
message: 'LXC configuration saved successfully'
message
};
} catch (error) {
console.error('Error in saveLXCConfig:', error);