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

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:
2026-06-14 19:30:55 +05:00
parent bc462c9f8e
commit 2886326353
6 changed files with 217 additions and 14 deletions

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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",

View File

@@ -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": "В очереди",