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
29
server.log
29
server.log
@@ -1,29 +0,0 @@
|
|||||||
|
|
||||||
> pve-scripts-local@0.1.0 dev
|
|
||||||
> node server.js
|
|
||||||
|
|
||||||
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
Terminated
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Terminal } from './Terminal';
|
import { Terminal } from './Terminal';
|
||||||
import { StatusBadge } from './Badge';
|
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 [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
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 [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
|
// Fetch installed scripts
|
||||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
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 scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
|
||||||
const stats = statsData?.stats;
|
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
|
// Filter scripts based on search and filters
|
||||||
const filteredScripts = scripts.filter((script: InstalledScript) => {
|
const filteredScripts = scripts.filter((script: InstalledScript) => {
|
||||||
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@@ -197,6 +279,25 @@ export function InstalledScriptsTab() {
|
|||||||
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
|
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) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
@@ -251,8 +352,8 @@ export function InstalledScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Script Button */}
|
{/* Add Script and Auto-Detect Buttons */}
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
variant={showAddForm ? "outline" : "default"}
|
variant={showAddForm ? "outline" : "default"}
|
||||||
@@ -260,6 +361,13 @@ export function InstalledScriptsTab() {
|
|||||||
>
|
>
|
||||||
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
|
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Add Script Form */}
|
{/* Add Script Form */}
|
||||||
@@ -331,6 +439,145 @@ export function InstalledScriptsTab() {
|
|||||||
</div>
|
</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 */}
|
{/* Filters */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search Input - Full Width on Mobile */}
|
{/* Search Input - Full Width on Mobile */}
|
||||||
|
|||||||
@@ -203,5 +203,349 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
stats: null
|
stats: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Auto-detect LXC containers with community-script tag
|
||||||
|
autoDetectLXCContainers: publicProcedure
|
||||||
|
.input(z.object({ serverId: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
console.log('=== AUTO-DETECT API ENDPOINT CALLED ===');
|
||||||
|
console.log('Input received:', input);
|
||||||
|
console.log('Timestamp:', new Date().toISOString());
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Starting auto-detect LXC containers for server ID:', input.serverId);
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const server = db.getServerById(input.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
console.error('Server not found for ID:', input.serverId);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found',
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found server:', (server as any).name, 'at', (server as any).ip);
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection first
|
||||||
|
console.log('Testing SSH connection...');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
console.log('SSH connection test result:', connectionTest);
|
||||||
|
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SSH connection successful, scanning for LXC containers...');
|
||||||
|
|
||||||
|
// Use the working approach - manual loop through all config files
|
||||||
|
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
|
||||||
|
let detectedContainers: any[] = [];
|
||||||
|
|
||||||
|
console.log('Executing manual loop command...');
|
||||||
|
console.log('Command:', command);
|
||||||
|
|
||||||
|
let commandOutput = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
command,
|
||||||
|
(data: string) => {
|
||||||
|
console.log('Command output chunk:', data);
|
||||||
|
commandOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.error('Command error:', error);
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
console.log('Command exit code:', exitCode);
|
||||||
|
console.log('Full command output:', commandOutput);
|
||||||
|
|
||||||
|
// Parse the complete output to get config file paths that contain community-script tag
|
||||||
|
const configFiles = commandOutput.split('\n')
|
||||||
|
.filter((line: string) => line.trim())
|
||||||
|
.map((line: string) => line.trim())
|
||||||
|
.filter((line: string) => line.endsWith('.conf'));
|
||||||
|
|
||||||
|
console.log('Found config files with community-script tag:', configFiles.length);
|
||||||
|
console.log('Config files:', configFiles);
|
||||||
|
|
||||||
|
// Process each config file to extract hostname
|
||||||
|
const processPromises = configFiles.map(async (configPath: string) => {
|
||||||
|
try {
|
||||||
|
const containerId = configPath.split('/').pop()?.replace('.conf', '');
|
||||||
|
if (!containerId) return null;
|
||||||
|
|
||||||
|
console.log('Processing container:', containerId, 'from', configPath);
|
||||||
|
|
||||||
|
// Read the config file content
|
||||||
|
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return new Promise<any>((readResolve) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
readCommand,
|
||||||
|
(configData: string) => {
|
||||||
|
console.log('Config data for', containerId, ':', configData.substring(0, 300) + '...');
|
||||||
|
|
||||||
|
// Parse config file for hostname
|
||||||
|
const lines = configData.split('\n');
|
||||||
|
let hostname = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith('hostname:')) {
|
||||||
|
hostname = trimmedLine.substring(9).trim();
|
||||||
|
console.log('Found hostname for', containerId, ':', hostname);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname) {
|
||||||
|
const container = {
|
||||||
|
containerId,
|
||||||
|
hostname,
|
||||||
|
configPath,
|
||||||
|
serverId: (server as any).id,
|
||||||
|
serverName: (server as any).name
|
||||||
|
};
|
||||||
|
console.log('Adding container to detected list:', container);
|
||||||
|
readResolve(container);
|
||||||
|
} else {
|
||||||
|
console.log('No hostname found for', containerId);
|
||||||
|
readResolve(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(readError: string) => {
|
||||||
|
console.error(`Error reading config file ${configPath}:`, readError);
|
||||||
|
readResolve(null);
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
readResolve(null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing config file ${configPath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all config files to be processed
|
||||||
|
void Promise.all(processPromises).then((results) => {
|
||||||
|
detectedContainers = results.filter(result => result !== null);
|
||||||
|
console.log('Final detected containers:', detectedContainers.length);
|
||||||
|
resolve();
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error processing config files:', error);
|
||||||
|
reject(new Error(`Error processing config files: ${error}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Detected containers:', detectedContainers.length);
|
||||||
|
|
||||||
|
// Get existing scripts to check for duplicates
|
||||||
|
const existingScripts = db.getAllInstalledScripts();
|
||||||
|
console.log('Existing scripts in database:', existingScripts.length);
|
||||||
|
|
||||||
|
// Create installed script records for detected containers (skip duplicates)
|
||||||
|
const createdScripts = [];
|
||||||
|
const skippedScripts = [];
|
||||||
|
|
||||||
|
for (const container of detectedContainers) {
|
||||||
|
try {
|
||||||
|
// Check if a script with this container_id and server_id already exists
|
||||||
|
const duplicate = existingScripts.find((script: any) =>
|
||||||
|
script.container_id === container.containerId &&
|
||||||
|
script.server_id === container.serverId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
console.log(`Skipping duplicate: ${container.hostname} (${container.containerId}) already exists`);
|
||||||
|
skippedScripts.push({
|
||||||
|
containerId: container.containerId,
|
||||||
|
hostname: container.hostname,
|
||||||
|
serverName: container.serverName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Creating script record for:', container.hostname, container.containerId);
|
||||||
|
const result = db.createInstalledScript({
|
||||||
|
script_name: container.hostname,
|
||||||
|
script_path: `detected/${container.hostname}`,
|
||||||
|
container_id: container.containerId,
|
||||||
|
server_id: container.serverId,
|
||||||
|
execution_mode: 'ssh',
|
||||||
|
status: 'success',
|
||||||
|
output_log: `Auto-detected from LXC config: ${container.configPath}`
|
||||||
|
});
|
||||||
|
|
||||||
|
createdScripts.push({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
containerId: container.containerId,
|
||||||
|
hostname: container.hostname,
|
||||||
|
serverName: container.serverName
|
||||||
|
});
|
||||||
|
console.log('Created script record with ID:', result.lastInsertRowid);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating script record for ${container.hostname}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = skippedScripts.length > 0
|
||||||
|
? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
|
||||||
|
: `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: message,
|
||||||
|
detectedContainers: createdScripts,
|
||||||
|
skippedContainers: skippedScripts
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in autoDetectLXCContainers:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to auto-detect LXC containers',
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Cleanup orphaned scripts (check if LXC containers still exist on servers)
|
||||||
|
cleanupOrphanedScripts: publicProcedure
|
||||||
|
.mutation(async () => {
|
||||||
|
try {
|
||||||
|
console.log('=== CLEANUP ORPHANED SCRIPTS API ENDPOINT CALLED ===');
|
||||||
|
console.log('Timestamp:', new Date().toISOString());
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const allScripts = db.getAllInstalledScripts();
|
||||||
|
const allServers = db.getAllServers();
|
||||||
|
|
||||||
|
console.log('Found scripts:', allScripts.length);
|
||||||
|
console.log('Found servers:', allServers.length);
|
||||||
|
|
||||||
|
if (allScripts.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'No scripts to check',
|
||||||
|
deletedCount: 0,
|
||||||
|
deletedScripts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
const deletedScripts: string[] = [];
|
||||||
|
const scriptsToCheck = allScripts.filter((script: any) =>
|
||||||
|
script.execution_mode === 'ssh' &&
|
||||||
|
script.server_id &&
|
||||||
|
script.container_id
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Scripts to check for cleanup:', scriptsToCheck.length);
|
||||||
|
|
||||||
|
for (const script of scriptsToCheck) {
|
||||||
|
try {
|
||||||
|
const scriptData = script as any;
|
||||||
|
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||||
|
if (!server) {
|
||||||
|
console.log(`Server not found for script ${scriptData.script_name}, marking for deletion`);
|
||||||
|
db.deleteInstalledScript(Number(scriptData.id));
|
||||||
|
deletedScripts.push(String(scriptData.script_name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Checking script ${scriptData.script_name} on server ${(server as any).name}`);
|
||||||
|
|
||||||
|
// Test SSH connection
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
console.log(`SSH connection failed for server ${(server as any).name}, skipping script ${scriptData.script_name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the container config file still exists
|
||||||
|
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
|
||||||
|
|
||||||
|
const containerExists = await new Promise<boolean>((resolve) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
checkCommand,
|
||||||
|
(data: string) => {
|
||||||
|
console.log(`Container check result for ${scriptData.script_name}:`, data.trim());
|
||||||
|
resolve(data.trim() === 'exists');
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.error(`Error checking container ${scriptData.script_name}:`, error);
|
||||||
|
resolve(false);
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!containerExists) {
|
||||||
|
console.log(`Container ${scriptData.container_id} not found on server ${(server as any).name}, deleting script ${scriptData.script_name}`);
|
||||||
|
db.deleteInstalledScript(Number(scriptData.id));
|
||||||
|
deletedScripts.push(String(scriptData.script_name));
|
||||||
|
} else {
|
||||||
|
console.log(`Container ${scriptData.container_id} still exists on server ${(server as any).name}, keeping script ${scriptData.script_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking script ${(script as any).script_name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Cleanup completed. Deleted scripts:', deletedScripts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Cleanup completed. ${deletedScripts.length} orphaned script(s) removed.`,
|
||||||
|
deletedCount: deletedScripts.length,
|
||||||
|
deletedScripts: deletedScripts
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in cleanupOrphanedScripts:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to cleanup orphaned scripts',
|
||||||
|
deletedCount: 0,
|
||||||
|
deletedScripts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user