Files
ProxmoxVE-Local/src/app/_components/InstalledScriptsTab.tsx
Michel Roegl-Brunner b366a33f07 Remove execution_mode dependencies from InstalledScriptsTab (#50)
- Remove ExecutionModeBadge import and usage
- Update filtering logic to use server_name presence instead of execution_mode
- Simplify update script logic by removing mode property
- Update Terminal component call to determine mode based on server presence
- Replace ExecutionModeBadge in table with simple text display
- Maintain backend API compatibility by keeping execution_mode in mutations
- Use nullish coalescing operator (??) for better null handling
2025-10-07 09:59:10 +02:00

511 lines
20 KiB
TypeScript

'use client';
import { useState } from 'react';
import { api } from '~/trpc/react';
import { Terminal } from './Terminal';
import { StatusBadge } from './Badge';
import { Button } from './ui/button';
interface InstalledScript {
id: number;
script_name: string;
script_path: string;
container_id: string | null;
server_id: number | null;
server_name: string | null;
server_ip: string | null;
server_user: string | null;
server_password: string | null;
installation_date: string;
status: 'in_progress' | 'success' | 'failed';
output_log: string | null;
}
export function InstalledScriptsTab() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
const [serverFilter, setServerFilter] = useState<string>('all');
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
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' });
// Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
const { data: serversData } = api.servers.getAllServers.useQuery();
// Delete script mutation
const deleteScriptMutation = api.installedScripts.deleteInstalledScript.useMutation({
onSuccess: () => {
void refetchScripts();
}
});
// Update script mutation
const updateScriptMutation = api.installedScripts.updateInstalledScript.useMutation({
onSuccess: () => {
void refetchScripts();
setEditingScriptId(null);
setEditFormData({ script_name: '', container_id: '' });
},
onError: (error) => {
alert(`Error updating script: ${error.message}`);
}
});
// Create script mutation
const createScriptMutation = api.installedScripts.createInstalledScript.useMutation({
onSuccess: () => {
void refetchScripts();
setShowAddForm(false);
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
},
onError: (error) => {
alert(`Error creating script: ${error.message}`);
}
});
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
const stats = statsData?.stats;
// Filter scripts based on search and filters
const filteredScripts = scripts.filter((script: InstalledScript) => {
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer;
});
// Get unique servers for filter
const uniqueServers: string[] = [];
const seen = new Set<string>();
for (const script of scripts) {
if (script.server_name && !seen.has(String(script.server_name))) {
uniqueServers.push(String(script.server_name));
seen.add(String(script.server_name));
}
}
const handleDeleteScript = (id: number) => {
if (confirm('Are you sure you want to delete this installation record?')) {
void deleteScriptMutation.mutate({ id });
}
};
const handleUpdateScript = (script: InstalledScript) => {
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
};
}
setUpdatingScript({
id: script.id,
containerId: script.container_id,
server: server
});
}
};
const handleCloseUpdateTerminal = () => {
setUpdatingScript(null);
};
const handleEditScript = (script: InstalledScript) => {
setEditingScriptId(script.id);
setEditFormData({
script_name: script.script_name,
container_id: script.container_id ?? ''
});
};
const handleCancelEdit = () => {
setEditingScriptId(null);
setEditFormData({ script_name: '', container_id: '' });
};
const handleSaveEdit = () => {
if (!editFormData.script_name.trim()) {
alert('Script name is required');
return;
}
if (editingScriptId) {
updateScriptMutation.mutate({
id: editingScriptId,
script_name: editFormData.script_name.trim(),
container_id: editFormData.container_id.trim() || undefined,
});
}
};
const handleInputChange = (field: 'script_name' | 'container_id', value: string) => {
setEditFormData(prev => ({
...prev,
[field]: value
}));
};
const handleAddFormChange = (field: 'script_name' | 'container_id' | 'server_id', value: string) => {
setAddFormData(prev => ({
...prev,
[field]: value
}));
};
const handleAddScript = () => {
if (!addFormData.script_name.trim()) {
alert('Script name is required');
return;
}
createScriptMutation.mutate({
script_name: addFormData.script_name.trim(),
script_path: `manual/${addFormData.script_name.trim()}`,
container_id: addFormData.container_id.trim() || undefined,
server_id: addFormData.server_id === 'local' ? undefined : Number(addFormData.server_id),
execution_mode: addFormData.server_id === 'local' ? 'local' : 'ssh',
status: 'success'
});
};
const handleCancelAdd = () => {
setShowAddForm(false);
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading installed scripts...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Update Terminal */}
{updatingScript && (
<div className="mb-8">
<Terminal
scriptPath={`update-${updatingScript.containerId}`}
onClose={handleCloseUpdateTerminal}
mode={updatingScript.server ? 'ssh' : 'local'}
server={updatingScript.server}
isUpdate={true}
containerId={updatingScript.containerId}
/>
</div>
)}
{/* Header with Stats */}
<div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-400">{stats.total}</div>
<div className="text-sm text-blue-300">Total Installations</div>
</div>
<div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-400">{stats.byStatus.success}</div>
<div className="text-sm text-green-300">Successful</div>
</div>
<div className="bg-red-500/10 border border-red-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-red-400">{stats.byStatus.failed}</div>
<div className="text-sm text-red-300">Failed</div>
</div>
<div className="bg-yellow-500/10 border border-yellow-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-yellow-400">{stats.byStatus.in_progress}</div>
<div className="text-sm text-yellow-300">In Progress</div>
</div>
</div>
)}
{/* Add Script Button */}
<div className="mb-4">
<Button
onClick={() => setShowAddForm(!showAddForm)}
variant={showAddForm ? "outline" : "default"}
size="default"
>
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
</Button>
</div>
{/* Add Script Form */}
{showAddForm && (
<div className="mb-6 p-6 bg-card rounded-lg border border-border shadow-sm">
<h3 className="text-lg font-semibold text-foreground mb-6">Add Manual Script Entry</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Script Name *
</label>
<input
type="text"
value={addFormData.script_name}
onChange={(e) => handleAddFormChange('script_name', e.target.value)}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Enter script name"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Container ID
</label>
<input
type="text"
value={addFormData.container_id}
onChange={(e) => handleAddFormChange('container_id', e.target.value)}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Enter container ID"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Server
</label>
<select
value={addFormData.server_id}
onChange={(e) => handleAddFormChange('server_id', 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="local">Select Server</option>
{serversData?.servers?.map((server: any) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={handleCancelAdd}
variant="outline"
size="default"
>
Cancel
</Button>
<Button
onClick={handleAddScript}
disabled={createScriptMutation.isPending}
variant="default"
size="default"
>
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
</Button>
</div>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-64">
<input
type="text"
placeholder="Search scripts, container IDs, or servers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="all">All Status</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="in_progress">In Progress</option>
</select>
<select
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="all">All Servers</option>
<option value="local">Local</option>
{uniqueServers.map(server => (
<option key={server} value={server}>{server}</option>
))}
</select>
</div>
</div>
{/* Scripts Table */}
<div className="bg-card rounded-lg shadow overflow-hidden">
{filteredScripts.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Script Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Container ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Server
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Installation Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-gray-200">
{filteredScripts.map((script) => (
<tr key={script.id} className="hover:bg-accent">
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="space-y-2">
<input
type="text"
value={editFormData.script_name}
onChange={(e) => handleInputChange('script_name', e.target.value)}
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Script name"
/>
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
) : (
<div>
<div className="text-sm font-medium text-foreground">{script.script_name}</div>
<div className="text-sm text-muted-foreground">{script.script_path}</div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<input
type="text"
value={editFormData.container_id}
onChange={(e) => handleInputChange('container_id', e.target.value)}
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Container ID"
/>
) : (
script.container_id ? (
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-muted-foreground">
{script.server_name ?? 'Local'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()}
</StatusBadge>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{formatDate(String(script.installation_date))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{editingScriptId === script.id ? (
<>
<Button
onClick={handleSaveEdit}
disabled={updateScriptMutation.isPending}
variant="default"
size="sm"
>
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={handleCancelEdit}
variant="outline"
size="sm"
>
Cancel
</Button>
</>
) : (
<>
<Button
onClick={() => handleEditScript(script)}
variant="default"
size="sm"
>
Edit
</Button>
{script.container_id && (
<Button
onClick={() => handleUpdateScript(script)}
variant="link"
size="sm"
>
Update
</Button>
)}
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="destructive"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}