mirror of
https://github.com/community-scripts/ProxmoxVE-Local.git
synced 2026-07-24 22:44:00 -04:00
- 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
511 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|