feat: Add LXC Container Control Features (#124)

* feat: Add LXC container control functionality to Installed Scripts page

- Add reusable ConfirmationModal component with simple and type-to-confirm variants
- Add three new tRPC endpoints for container control:
  - getContainerStatus: Check container running/stopped state via pct status
  - controlContainer: Start/stop containers via pct start/stop commands
  - destroyContainer: Destroy containers via pct destroy and delete DB records
- Enhance InstalledScriptsTab with container status management and confirmation flows
- Update ScriptInstallationCard with Start/Stop and Destroy buttons for SSH scripts
- Add container control buttons to desktop table view with proper status handling
- Update help documentation with comprehensive container control feature guide
- Implement safety features:
  - Simple OK/Cancel confirmation for start/stop actions
  - Type-to-confirm modal requiring container ID for destroy actions
  - SSH connection validation and error handling
  - Loading states and user feedback for all operations
- Only show control buttons for SSH scripts with valid container IDs
- Maintain backward compatibility with existing delete functionality for non-SSH scripts

All container control operations execute via SSH using existing infrastructure.
Real-time container status checking and caching for optimal performance.

* fix: Resolve linting errors in LXC control functionality

- Remove unused getStatusMutation variable
- Fix floating promises by adding void operator
- Add missing dependencies to useEffect hooks
- Fix unsafe argument types by casting server IDs to Number
- Remove unused commandOutput variables
- Use useCallback for fetchContainerStatus to fix dependency issues
- Move function definition before usage to resolve hoisting errors

* fix: Add missing execution_mode property to InstalledScript interface in ScriptInstallationCard

- Add execution_mode: local | ssh property to InstalledScript interface
- Fixes TypeScript build error when checking script.execution_mode === ssh
- Ensures type consistency across all components

* fix: Resolve status detection conflicts by using unified bulk fetching

- Remove individual fetchContainerStatus function that was conflicting with bulk fetching
- Update controlContainerMutation to use fetchContainerStatuses instead of individual calls
- Remove unused utils variable to clean up linting warnings
- Simplify status detection to use only the bulk getContainerStatuses endpoint
- This should resolve the status detection issues by eliminating competing fetch mechanisms

* fix: Stop infinite API call loops that were overwhelming the server

- Remove fetchContainerStatuses from useEffect dependencies to prevent infinite loops
- Use useRef to access current scripts without causing dependency cycles
- Reduce multiple useEffect hooks that were all triggering status checks
- This should stop the 30+ simultaneous API calls that were redlining the server
- Status checks now happen only when needed: on load, after operations, and every 60s

* feat: Implement efficient pct list approach for container status checking

- Replace individual container status checks with bulk pct list per server
- Update getContainerStatuses to run pct list once per server and parse all results
- Simplify frontend to just pass server IDs instead of individual container data
- Much more efficient: 1 SSH call per server instead of 1 call per container
- Parse pct list output format: CTID Status Name
- Map pct list status (running/stopped) to our status format
- This should resolve the server overload issues while maintaining functionality

* fix: Remove duplicate container status display from STATUS column

- Remove container runtime status from STATUS column in both desktop and mobile views
- Keep container status display next to container ID where it belongs
- STATUS column now only shows installation status (SUCCESS/FAILED)
- Container runtime status (running/stopped) remains next to container ID
- Cleaner UI with no duplicate status information

* feat: Trigger status check when switching to installed scripts tab

- Add useEffect hook that triggers fetchContainerStatuses when component mounts
- This ensures container statuses are refreshed every time user switches to the tab
- Improves user experience by always showing current container states
- Uses empty dependency array to run only once per tab switch

* cleanup: Remove all console.log statements from codebase

- Remove console.log statements from InstalledScriptsTab.tsx
- Remove console.log statements from installedScripts.ts router
- Remove console.log statements from VersionDisplay.tsx
- Remove console.log statements from ScriptsGrid.tsx
- Keep console.error statements for proper error logging
- Cleaner production logs without debug output

* feat: Display detailed SSH error messages for container operations

- Capture both stdout and stderr from pct start/stop/destroy commands
- Show actual SSH error output to users instead of generic error messages
- Update controlContainer and destroyContainer to return detailed error messages
- Improve frontend error handling to display backend error messages
- Users now see specific error details like permission denied, container not found, etc.
- Better debugging experience with meaningful error feedback

* feat: Auto-stop containers before destroy and improve error UI

- Automatically stop running containers before destroying them
- Create custom ErrorModal component to replace ugly browser alerts
- Support both error and success modal types with appropriate styling
- Show detailed SSH error messages in a beautiful modal interface
- Update destroy success message to indicate if container was stopped first
- Better UX with consistent design language and proper error handling
- Auto-close modals after 10 seconds for better user experience

* fix: Replace dialog component with custom modal implementation

- Remove dependency on non-existent dialog component
- Use same modal pattern as ConfirmationModal for consistency
- Custom modal with backdrop, proper styling, and responsive design
- Maintains all functionality while fixing module resolution error
- Consistent with existing codebase patterns

* feat: Add instant success feedback for container start/stop operations

- Show success modal immediately after start/stop operations
- Update container status in UI instantly before background status check
- Prevents user confusion by showing expected status change immediately
- Add containerId to backend response for proper script identification
- Success modals show appropriate messages for start vs stop operations
- Background status check still runs to ensure accuracy
- Better UX with instant visual feedback

* fix: Improve Container Control section styling in help modal

- Replace bright red styling with subtle accent colors
- Use consistent design language that matches the rest of the interface
- Change safety features from red to yellow warning styling
- Better visual hierarchy and readability
- Maintains warning importance while being less jarring

* fix: Make safety features section much more subtle in help modal

- Replace bright yellow with muted background colors
- Use standard text colors (text-foreground, text-muted-foreground)
- Maintains warning icon but with consistent styling
- Much less jarring against dark theme
- Better integration with overall design language

* feat: Replace update script alerts with custom confirmation modal

- Replace browser alert() with custom ErrorModal for validation errors
- Replace browser confirm() with custom ConfirmationModal for update confirmation
- Add type-to-confirm safety feature requiring container ID input
- Include data loss warning and backup recommendation in confirmation message
- Consistent UI/UX with other confirmation dialogs
- Better error messaging with detailed information

* fix: Resolve all build errors and warnings

- Fix nullish coalescing operator warnings (|| to ??)
- Remove unused imports and variables
- Fix TypeScript type errors with proper casting
- Update ConfirmationModal state type to include missing properties
- Fix useEffect dependency warnings
- All build errors resolved, only minor unused variable warning remains
- Build now passes successfully

* feat: Disable update button when container is stopped

- Add disabled condition to update button in table view
- Add disabled condition to update button in mobile card view
- Prevents users from updating stopped containers
- Uses containerStatus to determine if button should be disabled
- Improves UX by preventing invalid operations on stopped containers

* fix: Resolve infinite loop in status updates

- Remove containerStatusMutation from fetchContainerStatuses dependencies
- Use empty dependency array for fetchContainerStatuses useCallback
- Remove fetchContainerStatuses from useEffect dependencies
- Only depend on scripts.length to prevent infinite loops
- Status updates now run only when scripts change, not on every render

* fix: Correct misleading text in update confirmation modal

- Change "will re-run the script installation process" to "will update the script"
- More accurate description of what the update operation actually does
- Maintains warning about potential container impact and backup recommendation
- Better user understanding of the actual operation being performed

* refactor: Remove all comments from InstalledScriptsTab.tsx

- Remove all single-line comments (//)
- Remove all multi-line comments (/* */)
- Clean up excessive empty lines
- Improve code readability and reduce file size
- Maintain all functionality while removing documentation comments

* refactor: Improve code organization and add comprehensive comments

- Add clear section comments for better code organization
- Document all major state variables and their purposes
- Add detailed comments for complex logic and operations
- Improve readability with better spacing and structure
- Maintain all existing functionality while improving maintainability
- Add comments for container control, mutations, and UI sections
This commit is contained in:
Michel Roegl-Brunner
2025-10-13 16:36:11 +02:00
committed by GitHub
parent 53b5074f35
commit 5b45293b4d
8 changed files with 1010 additions and 281 deletions

View File

@@ -6,6 +6,8 @@ import { Terminal } from './Terminal';
import { StatusBadge } from './Badge';
import { Button } from './ui/button';
import { ScriptInstallationCard } from './ScriptInstallationCard';
import { ConfirmationModal } from './ConfirmationModal';
import { ErrorModal } from './ErrorModal';
import { getContrastColor } from '../../lib/colorUtils';
interface InstalledScript {
@@ -22,6 +24,7 @@ interface InstalledScript {
installation_date: string;
status: 'in_progress' | 'success' | 'failed';
output_log: string | null;
execution_mode: 'local' | 'ssh';
container_status?: 'running' | 'stopped' | 'unknown';
}
@@ -41,7 +44,30 @@ export function InstalledScriptsTab() {
const [autoDetectStatus, setAutoDetectStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
const cleanupRunRef = useRef(false);
const [containerStatuses, setContainerStatuses] = useState<Record<string, 'running' | 'stopped' | 'unknown'>>({});
// Container control state
const [containerStatuses, setContainerStatuses] = useState<Map<number, 'running' | 'stopped' | 'unknown'>>(new Map());
const [confirmationModal, setConfirmationModal] = useState<{
isOpen: boolean;
variant: 'simple' | 'danger';
title: string;
message: string;
confirmText?: string;
confirmButtonText?: string;
cancelButtonText?: string;
onConfirm: () => void;
} | null>(null);
const [controllingScriptId, setControllingScriptId] = useState<number | null>(null);
const scriptsRef = useRef<InstalledScript[]>([]);
// Error modal state
const [errorModal, setErrorModal] = useState<{
isOpen: boolean;
title: string;
message: string;
details?: string;
type?: 'error' | 'success';
} | null>(null);
// Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
@@ -82,7 +108,6 @@ export function InstalledScriptsTab() {
// Auto-detect LXC containers mutation
const autoDetectMutation = api.installedScripts.autoDetectLXCContainers.useMutation({
onSuccess: (data) => {
console.log('Auto-detect success:', data);
void refetchScripts();
setShowAutoDetectForm(false);
setAutoDetectServerId('');
@@ -120,7 +145,28 @@ export function InstalledScriptsTab() {
const containerStatusMutation = api.installedScripts.getContainerStatuses.useMutation({
onSuccess: (data) => {
if (data.success) {
setContainerStatuses(data.statusMap);
// Map container IDs to script IDs
const currentScripts = scriptsRef.current;
const statusMap = new Map<number, 'running' | 'stopped' | 'unknown'>();
// For each script, find its container status
currentScripts.forEach(script => {
if (script.container_id && data.statusMap) {
const containerStatus = (data.statusMap as Record<string, 'running' | 'stopped' | 'unknown'>)[script.container_id];
if (containerStatus) {
statusMap.set(script.id, containerStatus);
} else {
statusMap.set(script.id, 'unknown');
}
} else {
statusMap.set(script.id, 'unknown');
}
});
setContainerStatuses(statusMap);
} else {
console.error('Container status fetch failed:', data.error);
}
},
onError: (error) => {
@@ -131,7 +177,6 @@ export function InstalledScriptsTab() {
// Cleanup orphaned scripts mutation
const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({
onSuccess: (data) => {
console.log('Cleanup success:', data);
void refetchScripts();
if (data.deletedCount > 0) {
@@ -159,76 +204,149 @@ export function InstalledScriptsTab() {
}
});
// Container control mutations
// Note: getStatusMutation removed - using direct API calls instead
const controlContainerMutation = api.installedScripts.controlContainer.useMutation({
onSuccess: (data, variables) => {
setControllingScriptId(null);
if (data.success) {
// Update container status immediately in UI for instant feedback
const newStatus = variables.action === 'start' ? 'running' : 'stopped';
setContainerStatuses(prev => {
const newMap = new Map(prev);
// Find the script ID for this container using the container ID from the response
const currentScripts = scriptsRef.current;
const script = currentScripts.find(s => s.container_id === data.containerId);
if (script) {
newMap.set(script.id, newStatus);
}
return newMap;
});
// Show success modal
setErrorModal({
isOpen: true,
title: `Container ${variables.action === 'start' ? 'Started' : 'Stopped'}`,
message: data.message ?? `Container has been ${variables.action === 'start' ? 'started' : 'stopped'} successfully.`,
details: undefined,
type: 'success'
});
// Re-fetch status for all containers using bulk method (in background)
fetchContainerStatuses();
} else {
// Show error message from backend
const errorMessage = data.error ?? 'Unknown error occurred';
setErrorModal({
isOpen: true,
title: 'Container Control Failed',
message: 'Failed to control the container. Please check the error details below.',
details: errorMessage
});
}
},
onError: (error) => {
console.error('Container control error:', error);
setControllingScriptId(null);
// Show detailed error message
const errorMessage = error.message ?? 'Unknown error occurred';
setErrorModal({
isOpen: true,
title: 'Container Control Failed',
message: 'An unexpected error occurred while controlling the container.',
details: errorMessage
});
}
});
const destroyContainerMutation = api.installedScripts.destroyContainer.useMutation({
onSuccess: (data) => {
setControllingScriptId(null);
if (data.success) {
void refetchScripts();
setErrorModal({
isOpen: true,
title: 'Container Destroyed',
message: data.message ?? 'The container has been successfully destroyed and removed from the database.',
details: undefined,
type: 'success'
});
} else {
// Show error message from backend
const errorMessage = data.error ?? 'Unknown error occurred';
setErrorModal({
isOpen: true,
title: 'Container Destroy Failed',
message: 'Failed to destroy the container. Please check the error details below.',
details: errorMessage
});
}
},
onError: (error) => {
console.error('Container destroy error:', error);
setControllingScriptId(null);
// Show detailed error message
const errorMessage = error.message ?? 'Unknown error occurred';
setErrorModal({
isOpen: true,
title: 'Container Destroy Failed',
message: 'An unexpected error occurred while destroying the container.',
details: errorMessage
});
}
});
const scripts: InstalledScript[] = useMemo(() => (scriptsData?.scripts as InstalledScript[]) ?? [], [scriptsData?.scripts]);
const stats = statsData?.stats;
// Function to fetch container statuses
const fetchContainerStatuses = useCallback(() => {
console.log('Fetching container statuses...', { scriptsCount: scripts.length });
const containersWithIds = scripts
.filter(script => script.container_id)
.map(script => ({
containerId: script.container_id!,
serverId: script.server_id ?? undefined,
server: script.server_id ? {
id: script.server_id,
name: script.server_name!,
ip: script.server_ip!,
user: script.server_user!,
password: script.server_password!,
auth_type: 'password' // Default to password auth
} : undefined
}));
// Update ref when scripts change
useEffect(() => {
scriptsRef.current = scripts;
}, [scripts]);
console.log('Containers to check:', containersWithIds.length);
if (containersWithIds.length > 0) {
containerStatusMutation.mutate({ containers: containersWithIds });
// Function to fetch container statuses - simplified to just check all servers
const fetchContainerStatuses = useCallback(() => {
const currentScripts = scriptsRef.current;
// Get unique server IDs from scripts
const serverIds = [...new Set(currentScripts
.filter(script => script.server_id)
.map(script => script.server_id!))];
if (serverIds.length > 0) {
containerStatusMutation.mutate({ serverIds });
}
}, [scripts, containerStatusMutation]);
}, []); // Empty dependency array to prevent infinite loops
// Run cleanup when component mounts and scripts are loaded (only once)
useEffect(() => {
if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) {
console.log('Running automatic cleanup check...');
cleanupRunRef.current = true;
void cleanupMutation.mutate();
}
}, [scripts.length, serversData?.servers, cleanupMutation]);
// Auto-refresh container statuses every 60 seconds
// Note: Individual status fetching removed - using bulk fetchContainerStatuses instead
// Trigger status check when tab becomes active (component mounts)
useEffect(() => {
if (scripts.length > 0) {
fetchContainerStatuses(); // Initial fetch
const interval = setInterval(fetchContainerStatuses, 60000); // Every 60 seconds
return () => clearInterval(interval);
}
}, [scripts.length, fetchContainerStatuses]);
// Trigger status check when component becomes visible (tab is active)
useEffect(() => {
if (scripts.length > 0) {
// Small delay to ensure component is fully rendered
const timeoutId = setTimeout(() => {
fetchContainerStatuses();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [scripts.length, fetchContainerStatuses]); // Include dependencies
// Also trigger status check when scripts data loads
useEffect(() => {
if (scripts.length > 0 && !isLoading) {
console.log('Scripts data loaded, triggering status check');
fetchContainerStatuses();
}
}, [scriptsData, isLoading, scripts.length, fetchContainerStatuses]);
}, [scripts.length]); // Only depend on scripts.length to prevent infinite loops
// Update scripts with container statuses
const scriptsWithStatus = scripts.map(script => ({
...script,
container_status: script.container_id ? containerStatuses[script.container_id] ?? 'unknown' : undefined
container_status: script.container_id ? containerStatuses.get(script.id) ?? 'unknown' : undefined
}));
// Filter and sort scripts
@@ -323,31 +441,86 @@ export function InstalledScriptsTab() {
}
};
const handleUpdateScript = (script: InstalledScript) => {
// Container control handlers
const handleStartStop = (script: InstalledScript, action: 'start' | 'stop') => {
if (!script.container_id) {
alert('No Container ID available for this script');
return;
}
if (confirm(`Are you sure you want to update ${script.script_name}?`)) {
// Get server info if it's SSH mode
let server = null;
if (script.server_id && script.server_user && script.server_password) {
server = {
id: script.server_id,
name: script.server_name,
ip: script.server_ip,
user: script.server_user,
password: script.server_password
};
setConfirmationModal({
isOpen: true,
variant: 'simple',
title: `${action === 'start' ? 'Start' : 'Stop'} Container`,
message: `Are you sure you want to ${action} container ${script.container_id} (${script.script_name})?`,
onConfirm: () => {
setControllingScriptId(script.id);
void controlContainerMutation.mutate({ id: script.id, action });
setConfirmationModal(null);
}
setUpdatingScript({
id: script.id,
containerId: script.container_id,
server: server
});
});
};
const handleDestroy = (script: InstalledScript) => {
if (!script.container_id) {
alert('No Container ID available for this script');
return;
}
setConfirmationModal({
isOpen: true,
variant: 'danger',
title: 'Destroy Container',
message: `This will permanently destroy the LXC container ${script.container_id} (${script.script_name}) and all its data. This action cannot be undone!`,
confirmText: script.container_id,
onConfirm: () => {
setControllingScriptId(script.id);
void destroyContainerMutation.mutate({ id: script.id });
setConfirmationModal(null);
}
});
};
const handleUpdateScript = (script: InstalledScript) => {
if (!script.container_id) {
setErrorModal({
isOpen: true,
title: 'Update Failed',
message: 'No Container ID available for this script',
details: 'This script does not have a valid container ID and cannot be updated.'
});
return;
}
// Show confirmation modal with type-to-confirm for update
setConfirmationModal({
isOpen: true,
title: 'Confirm Script Update',
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',
onConfirm: () => {
// Get server info if it's SSH mode
let server = null;
if (script.server_id && script.server_user && script.server_password) {
server = {
id: script.server_id,
name: script.server_name,
ip: script.server_ip,
user: script.server_user,
password: script.server_password
};
}
setUpdatingScript({
id: script.id,
containerId: script.container_id!,
server: server
});
setConfirmationModal(null);
}
});
};
const handleCloseUpdateTerminal = () => {
@@ -369,7 +542,12 @@ export function InstalledScriptsTab() {
const handleSaveEdit = () => {
if (!editFormData.script_name.trim()) {
alert('Script name is required');
setErrorModal({
isOpen: true,
title: 'Validation Error',
message: 'Script name is required',
details: 'Please enter a valid script name before saving.'
});
return;
}
@@ -427,7 +605,6 @@ export function InstalledScriptsTab() {
}
setAutoDetectStatus({ type: null, message: '' });
console.log('Starting auto-detect for server ID:', autoDetectServerId);
autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) });
};
@@ -798,6 +975,10 @@ export function InstalledScriptsTab() {
onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending}
isDeleting={deleteScriptMutation.isPending}
containerStatus={containerStatuses.get(script.id) ?? 'unknown'}
onStartStop={(action) => handleStartStop(script, action)}
onDestroy={() => handleDestroy(script)}
isControlling={controllingScriptId === script.id}
/>
))}
</div>
@@ -993,18 +1174,43 @@ export function InstalledScriptsTab() {
onClick={() => handleUpdateScript(script)}
variant="update"
size="sm"
disabled={containerStatuses.get(script.id) === 'stopped'}
>
Update
</Button>
)}
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="delete"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
{/* Container Control Buttons - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<Button
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
variant={(containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'destructive' : 'default'}
size="sm"
>
{controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'}
</Button>
<Button
onClick={() => handleDestroy(script)}
disabled={controllingScriptId === script.id}
variant="destructive"
size="sm"
>
{controllingScriptId === script.id ? 'Working...' : 'Destroy'}
</Button>
</>
)}
{/* Fallback to old Delete button for non-SSH scripts */}
{(!script.container_id || script.execution_mode !== 'ssh') && (
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="delete"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
)}
</>
)}
</div>
@@ -1017,6 +1223,31 @@ export function InstalledScriptsTab() {
</>
)}
</div>
{/* Confirmation Modal */}
{confirmationModal && (
<ConfirmationModal
isOpen={confirmationModal.isOpen}
onClose={() => setConfirmationModal(null)}
onConfirm={confirmationModal.onConfirm}
title={confirmationModal.title}
message={confirmationModal.message}
variant={confirmationModal.variant}
confirmText={confirmationModal.confirmText}
/>
)}
{/* Error/Success Modal */}
{errorModal && (
<ErrorModal
isOpen={errorModal.isOpen}
onClose={() => setErrorModal(null)}
title={errorModal.title}
message={errorModal.message}
details={errorModal.details}
type={errorModal.type ?? 'error'}
/>
)}
</div>
);
}