feat: Add LXC auto-detection and cleanup of orphaned LXC (#80)

* feat: Add auto-detect LXC containers feature with improved UX

- Add auto-detection for LXC containers with 'community-script' tag
- SSH to Proxmox servers and scan /etc/pve/lxc/ config files
- Extract container ID and hostname from config files
- Automatically create installed script records for detected containers
- Replace alert popups with modern status messages
- Add visual feedback with success/error states
- Auto-close form on successful detection
- Add clear UI indicators for community-script tag requirement
- Improve error handling and logging for better debugging
- Support both local and SSH execution modes

* feat: Add automatic cleanup and duplicate prevention for LXC auto-detection

- Add automatic cleanup of orphaned LXC container scripts on tab load
- Implement duplicate checking to prevent re-adding existing scripts
- Replace flashy blue messages with subtle slate color scheme
- Add comprehensive status messages for cleanup and auto-detection
- Fix all ESLint errors and warnings
- Improve user experience with non-intrusive feedback
- Add detailed logging for debugging cleanup process
- Support both success and error states with appropriate styling
This commit is contained in:
Michel Roegl-Brunner
2025-10-08 16:20:36 +02:00
committed by GitHub
parent 5eaafbde48
commit 8b630c9201
3 changed files with 594 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { api } from '~/trpc/react';
import { Terminal } from './Terminal';
import { StatusBadge } from './Badge';
@@ -31,6 +31,11 @@ export function InstalledScriptsTab() {
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
const [showAddForm, setShowAddForm] = useState(false);
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
const [showAutoDetectForm, setShowAutoDetectForm] = useState(false);
const [autoDetectServerId, setAutoDetectServerId] = useState<string>('');
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);
// Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
@@ -68,10 +73,87 @@ 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('');
// Show detailed message about what was added/skipped
let statusMessage = data.message ?? 'Auto-detection completed successfully!';
if (data.skippedContainers && data.skippedContainers.length > 0) {
const skippedNames = data.skippedContainers.map((c: any) => String(c.hostname)).join(', ');
statusMessage += ` Skipped duplicates: ${skippedNames}`;
}
setAutoDetectStatus({
type: 'success',
message: statusMessage
});
// Clear status after 8 seconds (longer for detailed info)
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000);
},
onError: (error) => {
console.error('Auto-detect mutation error:', error);
console.error('Error details:', {
message: error.message,
data: error.data
});
setAutoDetectStatus({
type: 'error',
message: error.message ?? 'Auto-detection failed. Please try again.'
});
// Clear status after 5 seconds
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
}
});
// Cleanup orphaned scripts mutation
const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({
onSuccess: (data) => {
console.log('Cleanup success:', data);
void refetchScripts();
if (data.deletedCount > 0) {
setCleanupStatus({
type: 'success',
message: `Cleanup completed! Removed ${data.deletedCount} orphaned script(s): ${data.deletedScripts.join(', ')}`
});
} else {
setCleanupStatus({
type: 'success',
message: 'Cleanup completed! No orphaned scripts found.'
});
}
// Clear status after 8 seconds (longer for cleanup info)
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000);
},
onError: (error) => {
console.error('Cleanup mutation error:', error);
setCleanupStatus({
type: 'error',
message: error.message ?? 'Cleanup failed. Please try again.'
});
// Clear status after 5 seconds
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000);
}
});
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
const stats = statsData?.stats;
// 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]);
// Filter scripts based on search and filters
const filteredScripts = scripts.filter((script: InstalledScript) => {
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -197,6 +279,25 @@ export function InstalledScriptsTab() {
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
};
const handleAutoDetect = () => {
if (!autoDetectServerId) {
return;
}
if (autoDetectMutation.isPending) {
return;
}
setAutoDetectStatus({ type: null, message: '' });
console.log('Starting auto-detect for server ID:', autoDetectServerId);
autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) });
};
const handleCancelAutoDetect = () => {
setShowAutoDetectForm(false);
setAutoDetectServerId('');
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
@@ -251,8 +352,8 @@ export function InstalledScriptsTab() {
</div>
)}
{/* Add Script Button */}
<div className="mb-4">
{/* Add Script and Auto-Detect Buttons */}
<div className="mb-4 flex flex-col sm:flex-row gap-3">
<Button
onClick={() => setShowAddForm(!showAddForm)}
variant={showAddForm ? "outline" : "default"}
@@ -260,6 +361,13 @@ export function InstalledScriptsTab() {
>
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
</Button>
<Button
onClick={() => setShowAutoDetectForm(!showAutoDetectForm)}
variant={showAutoDetectForm ? "outline" : "secondary"}
size="default"
>
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
</Button>
</div>
{/* Add Script Form */}
@@ -331,6 +439,145 @@ export function InstalledScriptsTab() {
</div>
)}
{/* Status Messages */}
{(autoDetectStatus.type ?? cleanupStatus.type) && (
<div className="mb-4 space-y-2">
{/* Auto-Detect Status Message */}
{autoDetectStatus.type && (
<div className={`p-4 rounded-lg border ${
autoDetectStatus.type === 'success'
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
}`}>
<div className="flex items-center">
<div className="flex-shrink-0">
{autoDetectStatus.type === 'success' ? (
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="ml-3">
<p className={`text-sm font-medium ${
autoDetectStatus.type === 'success'
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}>
{autoDetectStatus.message}
</p>
</div>
</div>
</div>
)}
{/* Cleanup Status Message */}
{cleanupStatus.type && (
<div className={`p-4 rounded-lg border ${
cleanupStatus.type === 'success'
? 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700'
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
}`}>
<div className="flex items-center">
<div className="flex-shrink-0">
{cleanupStatus.type === 'success' ? (
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="ml-3">
<p className={`text-sm font-medium ${
cleanupStatus.type === 'success'
? 'text-slate-700 dark:text-slate-300'
: 'text-red-800 dark:text-red-200'
}`}>
{cleanupStatus.message}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Auto-Detect LXC Containers Form */}
{showAutoDetectForm && (
<div className="mb-6 p-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Auto-Detect LXC Containers (Must contain a tag with &quot;community-script&quot;)</h3>
<div className="space-y-4 sm:space-y-6">
<div className="bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">
How it works
</h4>
<div className="mt-2 text-sm text-slate-600 dark:text-slate-400">
<p>This feature will:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Connect to the selected server via SSH</li>
<li>Scan all LXC config files in /etc/pve/lxc/</li>
<li>Find containers with &quot;community-script&quot; in their tags</li>
<li>Extract the container ID and hostname</li>
<li>Add them as installed script entries</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Select Server *
</label>
<select
value={autoDetectServerId}
onChange={(e) => setAutoDetectServerId(e.target.value)}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
>
<option value="">Choose a server...</option>
{serversData?.servers?.map((server: any) => (
<option key={server.id} value={server.id}>
{server.name} ({server.ip})
</option>
))}
</select>
</div>
</div>
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
<Button
onClick={handleCancelAutoDetect}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleAutoDetect}
disabled={autoDetectMutation.isPending || !autoDetectServerId}
variant="default"
size="default"
className="w-full sm:w-auto"
>
{autoDetectMutation.isPending ? '🔍 Scanning...' : '🔍 Start Auto-Detection'}
</Button>
</div>
</div>
)}
{/* Filters */}
<div className="space-y-4">
{/* Search Input - Full Width on Mobile */}