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:
committed by
GitHub
parent
5eaafbde48
commit
8b630c9201
@@ -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 "community-script")</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 "community-script" 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 */}
|
||||
|
||||
Reference in New Issue
Block a user