feat(instances,tasks): add PVE task log viewer and VM script execution
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 52s
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 52s
Tasks page now shows the live Proxmox task log/status for UPID-backed tasks. Instance Settings tab gains an admin-only 'Execute script' card that runs a script in the guest via the QEMU agent and shows stdout/stderr/exit code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useVMConfig, useUpdateConfig, useResizeDisk, useVMOwner, useSetVMOwner } from '@/hooks/use-instances';
|
||||
import { useVMConfig, useUpdateConfig, useResizeDisk, useVMOwner, useSetVMOwner, useExecuteScript } from '@/hooks/use-instances';
|
||||
import { useProfile } from '@/hooks/use-settings';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
@@ -69,6 +69,74 @@ function OwnerCard({ serverId, vmid }: { serverId: number; vmid: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ExecuteScriptCard({ serverId, vmid, node }: { serverId: number; vmid: number; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: profile } = useProfile();
|
||||
const exec = useExecuteScript(serverId, vmid);
|
||||
const [script, setScript] = useState('');
|
||||
const [interpreter, setInterpreter] = useState('/bin/bash');
|
||||
const [timeout, setTimeoutVal] = useState('60');
|
||||
|
||||
if (!profile?.is_admin) return null;
|
||||
|
||||
const run = () => {
|
||||
if (!script.trim()) { toast.error(t('instances.script_required')); return; }
|
||||
exec.mutate(
|
||||
{ script, interpreter, node, timeout: Number(timeout) || 60 },
|
||||
{ onError: (e: Error) => toast.error(e.message) },
|
||||
);
|
||||
};
|
||||
|
||||
const r = exec.data;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />{t('instances.execute_script')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t('instances.execute_script_hint')}</p>
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={e => setScript(e.target.value)}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
placeholder={'uptime\ndf -h'}
|
||||
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('instances.interpreter')}</Label><Input value={interpreter} onChange={e => setInterpreter(e.target.value)} className="mt-1 font-mono" /></div>
|
||||
<div><Label>{t('instances.timeout_sec')}</Label><Input type="number" min={1} max={600} value={timeout} onChange={e => setTimeoutVal(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<Button size="sm" onClick={run} disabled={exec.isPending}>
|
||||
{exec.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('instances.run_script')}
|
||||
</Button>
|
||||
|
||||
{r && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('instances.exit_code')}:</span>
|
||||
<code className={r.exit_code === 0 ? 'text-green-600 dark:text-green-500' : 'text-destructive'}>{r.exit_code ?? '—'}</code>
|
||||
</div>
|
||||
{r.error && <p className="text-xs text-destructive">{r.error}</p>}
|
||||
{r.stdout != null && r.stdout !== '' && (
|
||||
<div><p className="text-xs font-medium text-muted-foreground">stdout</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">{r.stdout}</pre></div>
|
||||
)}
|
||||
{r.stderr != null && r.stderr !== '' && (
|
||||
<div><p className="text-xs font-medium text-muted-foreground">stderr</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap text-destructive">{r.stderr}</pre></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
@@ -122,6 +190,8 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<OwnerCard serverId={serverId} vmid={vmid} />
|
||||
|
||||
{type !== 'lxc' && <ExecuteScriptCard serverId={serverId} vmid={vmid} node={node} />}
|
||||
|
||||
{/* CPU & Memory */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, X, ClipboardList, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { Trash2, X, ClipboardList, RefreshCw, Loader2, FileText } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAllTasks, useCancelTask, useClearCompletedTasks } from '@/hooks/use-tasks';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useAllTasks, useCancelTask, useClearCompletedTasks, useTaskLog, useTaskStatus, type TaskItem } from '@/hooks/use-tasks';
|
||||
|
||||
export default function TasksPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,6 +17,7 @@ export default function TasksPage() {
|
||||
const tasks = tasksData?.tasks ?? [];
|
||||
const cancelTask = useCancelTask();
|
||||
const clearCompleted = useClearCompletedTasks();
|
||||
const [logTask, setLogTask] = useState<TaskItem | null>(null);
|
||||
|
||||
const filtered = tasks.filter(task => {
|
||||
if (statusFilter === 'all') return true;
|
||||
@@ -133,17 +135,30 @@ export default function TasksPage() {
|
||||
) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => cancelTask.mutate(Number(task.id ?? 0))}
|
||||
disabled={cancelTask.isPending}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{task.upid && task.node && task.server_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setLogTask(task)}
|
||||
title={t('tasks.view_log')}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => cancelTask.mutate(Number(task.id ?? 0))}
|
||||
disabled={cancelTask.isPending}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -159,6 +174,48 @@ export default function TasksPage() {
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TaskLogDialog task={logTask} onClose={() => setLogTask(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskLogDialog({ task, onClose }: { task: TaskItem | null; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const open = !!task;
|
||||
const serverId = task?.server_id ?? 0;
|
||||
const upid = task?.upid ?? '';
|
||||
const node = task?.node ?? '';
|
||||
// Poll while the task is still running.
|
||||
const running = task?.status === 'running' || task?.status === 'in_progress';
|
||||
const { data: statusData } = useTaskStatus(serverId, upid, node, open && running);
|
||||
const { data: logData, isLoading } = useTaskLog(serverId, upid, node, open);
|
||||
|
||||
const lines = logData?.logs ?? [];
|
||||
const status = statusData?.status ?? task?.status;
|
||||
const exit = statusData?.exitstatus as string | undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{t('tasks.task_log')}
|
||||
{status != null && <Badge variant="outline">{String(status)}</Badge>}
|
||||
{exit && exit !== 'OK' && <Badge variant="destructive">{exit}</Badge>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs font-mono text-muted-foreground break-all">{upid}</p>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
) : lines.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
|
||||
) : (
|
||||
<pre className="max-h-[60vh] overflow-auto rounded-md bg-muted p-3 text-xs whitespace-pre-wrap">
|
||||
{lines.map(l => l.t).join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,6 +280,40 @@ export function useSnapshotArchiveDetail(archiveId: number | null) {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Execute script (guest agent) ====================
|
||||
|
||||
export interface ExecuteScriptResult {
|
||||
success?: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exit_code?: number;
|
||||
error?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export function useExecuteScript(serverId: number, vmid: number) {
|
||||
return useMutation({
|
||||
// Endpoint takes script/interpreter as multipart form fields and node/timeout as query params,
|
||||
// so we bypass the JSON apiClient and post FormData directly.
|
||||
mutationFn: async ({ script, interpreter, node, timeout }: { script: string; interpreter: string; node: string; timeout: number }) => {
|
||||
const fd = new FormData();
|
||||
fd.set('script', script);
|
||||
fd.set('interpreter', interpreter);
|
||||
const qs = new URLSearchParams({ node, timeout: String(timeout) });
|
||||
const res = await fetch(`/proxmox/api/${serverId}/vm/${vmid}/execute-script?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiClient.getToken()}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
|
||||
throw new Error((e as { detail?: string }).detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<ExecuteScriptResult>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Power Actions ====================
|
||||
interface PowerActionResult {
|
||||
status: string;
|
||||
|
||||
@@ -48,6 +48,30 @@ export function useAllTasks(params?: { limit?: number; status?: string }) {
|
||||
});
|
||||
}
|
||||
|
||||
export interface TaskLogLine { n: number; t: string }
|
||||
|
||||
export function useTaskLog(serverId: number, upid: string, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['task-log', serverId, upid, node],
|
||||
queryFn: () => apiClient.get<{ logs: TaskLogLine[] }>(
|
||||
`/proxmox/api/${serverId}/task/${upid}/log?node=${encodeURIComponent(node)}&limit=500`,
|
||||
),
|
||||
enabled: enabled && serverId > 0 && !!upid && !!node,
|
||||
refetchInterval: enabled ? 3000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTaskStatus(serverId: number, upid: string, node: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['task-status', serverId, upid, node],
|
||||
queryFn: () => apiClient.get<Record<string, unknown>>(
|
||||
`/proxmox/api/${serverId}/task/${upid}/status?node=${encodeURIComponent(node)}`,
|
||||
),
|
||||
enabled: enabled && serverId > 0 && !!upid && !!node,
|
||||
refetchInterval: enabled ? 3000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveTaskCount() {
|
||||
return useQuery({
|
||||
queryKey: taskKeys.activeCount,
|
||||
|
||||
@@ -177,6 +177,13 @@
|
||||
"no_owner": "No owner",
|
||||
"owner_saved": "Owner updated",
|
||||
"owner_hint": "Assign this instance to a user (admin only).",
|
||||
"execute_script": "Execute script",
|
||||
"execute_script_hint": "Runs via the QEMU guest agent (must be installed and running in the VM).",
|
||||
"interpreter": "Interpreter",
|
||||
"timeout_sec": "Timeout (sec)",
|
||||
"run_script": "Run script",
|
||||
"script_required": "Script is empty",
|
||||
"exit_code": "Exit code",
|
||||
"search_placeholder": "Search by name, IP, or VMID...",
|
||||
"all_statuses": "All statuses",
|
||||
"all_nodes": "All nodes",
|
||||
@@ -747,6 +754,8 @@
|
||||
"no_users": "No users available"
|
||||
},
|
||||
"tasks": {
|
||||
"view_log": "View log",
|
||||
"task_log": "Task log",
|
||||
"clear_completed": "Clear Completed",
|
||||
"running": "Running",
|
||||
"queued": "Queued",
|
||||
|
||||
@@ -177,6 +177,13 @@
|
||||
"no_owner": "Без владельца",
|
||||
"owner_saved": "Владелец обновлён",
|
||||
"owner_hint": "Назначить инстанс пользователю (только админ).",
|
||||
"execute_script": "Выполнить скрипт",
|
||||
"execute_script_hint": "Выполняется через QEMU guest agent (должен быть установлен и запущен в VM).",
|
||||
"interpreter": "Интерпретатор",
|
||||
"timeout_sec": "Таймаут (сек)",
|
||||
"run_script": "Запустить скрипт",
|
||||
"script_required": "Скрипт пустой",
|
||||
"exit_code": "Код выхода",
|
||||
"search_placeholder": "Поиск по имени, IP или VMID...",
|
||||
"all_statuses": "Все статусы",
|
||||
"all_nodes": "Все узлы",
|
||||
@@ -747,6 +754,8 @@
|
||||
"no_users": "Пользователи недоступны"
|
||||
},
|
||||
"tasks": {
|
||||
"view_log": "Показать лог",
|
||||
"task_log": "Лог задачи",
|
||||
"clear_completed": "Очистить завершённые",
|
||||
"running": "Выполняется",
|
||||
"queued": "В очереди",
|
||||
|
||||
Reference in New Issue
Block a user