feat(instances): bulk restart action and live bulk-operation status tracking

Add a Restart button to the bulk-selection toolbar (backend already supports
bulk_restart) and track bulk operation progress like instance creation:
per-row status overlay on affected instances plus a progress bar driven by
task_update WebSocket events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 18:56:20 +05:00
parent 1d1bd33e03
commit fd47241394
5 changed files with 245 additions and 21 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect, useCallback } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -73,12 +73,13 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StatusDot } from '@/components/shared/status-dot'; import { StatusDot } from '@/components/shared/status-dot';
import { ColumnFilter, multiSelectFilter } from '@/components/shared/column-filter'; import { ColumnFilter, multiSelectFilter } from '@/components/shared/column-filter';
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync } from '@/hooks/use-instances'; import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync, useBulkTasksSync } from '@/hooks/use-instances';
import { useServers } from '@/hooks/use-nodes'; import { useServers } from '@/hooks/use-nodes';
import { useProfile } from '@/hooks/use-settings'; import { useProfile } from '@/hooks/use-settings';
import { formatBytes, vmTypeLabel, formatUptime, formatVmConfig } from '@/lib/format'; import { formatBytes, vmTypeLabel, formatUptime, formatVmConfig } from '@/lib/format';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store'; import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
import { useBulkTasksStore } from '@/stores/bulk-tasks-store';
import type { VMInstance } from '@/types'; import type { VMInstance } from '@/types';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -230,8 +231,23 @@ export default function InstancesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: vms, isLoading } = useVirtualMachines(); const { data: vms, isLoading } = useVirtualMachines();
useVMStatusSync(); useVMStatusSync();
useBulkTasksSync();
const metricsMap = useInstancesMetricsSync(); const metricsMap = useInstancesMetricsSync();
const bulkOp = useBulkOperation(); const bulkOp = useBulkOperation();
const bulkTasks = useBulkTasksStore((s) => s.tasks);
const addBulkTask = useBulkTasksStore((s) => s.addTask);
// Human-readable label for an in-flight bulk action (status overlay / progress).
const bulkActionLabel = useCallback((action: string) => {
switch (action) {
case 'start': return t('instances.status_starting', 'Запуск');
case 'stop': return t('instances.status_stopping', 'Остановка');
case 'restart': return t('instances.status_restarting', 'Перезагрузка');
case 'shutdown': return t('instances.status_shutting_down', 'Выключение');
case 'delete': return t('instances.status_deleting', 'Удаление');
default: return action;
}
}, [t]);
// ── Deploy task polling ────────────────────────────────────────── // ── Deploy task polling ──────────────────────────────────────────
const { tasks: deployTasks, updateTask, removeTask } = useDeployTasksStore(); const { tasks: deployTasks, updateTask, removeTask } = useDeployTasksStore();
@@ -371,6 +387,22 @@ export default function InstancesPage() {
return m; return m;
}, [deployTasks]); }, [deployTasks]);
// Map (server_id:vmid) -> in-flight bulk action for rows being processed by an
// active bulk task (start/stop/restart/...). An item drops out of the overlay
// once it shows up in the task's results (then the row reflects the live status).
const bulkOverlayByVm = useMemo(() => {
const m = new Map<string, { action: string }>();
for (const task of bulkTasks) {
if (task.status !== 'pending' && task.status !== 'running') continue;
const done = new Set(task.results.map((r) => `${r.server_id}:${r.vmid}`));
for (const it of task.items) {
const k = `${it.server_id}:${it.vmid}`;
if (!done.has(k)) m.set(k, { action: task.action });
}
}
return m;
}, [bulkTasks]);
const allRows = useMemo<VMInstance[]>(() => [...ghostRows, ...filteredVMs], [ghostRows, filteredVMs]); const allRows = useMemo<VMInstance[]>(() => [...ghostRows, ...filteredVMs], [ghostRows, filteredVMs]);
// Merge live WS metrics into rows for real-time rendering // Merge live WS metrics into rows for real-time rendering
@@ -409,6 +441,27 @@ export default function InstancesPage() {
return [...new Set(source.map((v) => v.node))].sort(); return [...new Set(source.map((v) => v.node))].sort();
}, [vms, serverFilter]); }, [vms, serverFilter]);
// The "node" column shows the panel-side server name (the name entered in the
// add-server form), not the Proxmox node hostname. server_id → server.name is
// the exact mapping; nodeName → server.name is a best-effort fallback used by
// the filter UI (assumes one node per server, which holds for option-2 setups).
const serverNameById = useMemo(() => {
const m = new Map<number, string>();
for (const s of servers) m.set(s.id, s.name);
return m;
}, [servers]);
const nodeLabel = useMemo(() => {
const m = new Map<string, string>();
for (const v of vms ?? []) {
if (v.node && v.server_id != null) {
const sn = serverNameById.get(v.server_id);
if (sn) m.set(v.node, sn);
}
}
return m;
}, [vms, serverNameById]);
// Drop a stale persisted node filter when that node is gone from the active // Drop a stale persisted node filter when that node is gone from the active
// workspace / selected server (same failure mode as the server filter above). // workspace / selected server (same failure mode as the server filter above).
useEffect(() => { useEffect(() => {
@@ -438,19 +491,34 @@ export default function InstancesPage() {
const handleBulk = (action: string) => { const handleBulk = (action: string) => {
if (!selectedVMs.length) return; if (!selectedVMs.length) return;
bulkOp.mutate( const items = selectedVMs.map((vm) => ({
{
action,
items: selectedVMs.map((vm) => ({
server_id: vm.server_id!, server_id: vm.server_id!,
vmid: vm.vmid, vmid: vm.vmid,
vm_type: vm.type, vm_type: vm.type,
name: vm.name || String(vm.vmid), name: vm.name || String(vm.vmid),
node: vm.node, node: vm.node,
})), }));
}, bulkOp.mutate(
{ action, items },
{ {
onSuccess: () => { toast.success(`Bulk ${action}: ${selectedVMs.length} items queued`); setRowSelection({}); }, onSuccess: (data) => {
// Register the task so the list can track its progress via WS
// (useBulkTasksSync), the same way instance creation is tracked.
if (data?.task_id) {
addBulkTask({
id: data.task_id,
action,
items: items.map((i) => ({ server_id: i.server_id, vmid: i.vmid, name: i.name })),
status: 'pending',
total: items.length,
completed: 0,
failed: 0,
results: [],
});
}
toast.success(`${bulkActionLabel(action)}: ${items.length}`);
setRowSelection({});
},
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
} }
); );
@@ -554,6 +622,17 @@ export default function InstancesPage() {
); );
} }
const status = vm.status; const status = vm.status;
const bulkOv = vm.server_id != null ? bulkOverlayByVm.get(`${vm.server_id}:${vm.vmid}`) : undefined;
if (bulkOv) {
return (
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
<Badge variant="secondary" className="bg-blue-500/10 text-blue-600 text-[10px]">
{bulkActionLabel(bulkOv.action)}
</Badge>
</div>
);
}
const overlay = vm.server_id != null ? overlayByVm.get(`${vm.server_id}:${vm.vmid}`) : undefined; const overlay = vm.server_id != null ? overlayByVm.get(`${vm.server_id}:${vm.vmid}`) : undefined;
if (overlay) { if (overlay) {
const label = overlay.kind === 'reinstall' const label = overlay.kind === 'reinstall'
@@ -594,11 +673,14 @@ export default function InstancesPage() {
{ {
accessorKey: 'node', accessorKey: 'node',
filterFn: multiSelectFilter, filterFn: multiSelectFilter,
meta: { filter: 'select' }, meta: { filter: 'select', formatOption: (v: string) => nodeLabel.get(v) ?? v },
header: t('common.node', 'Node'), header: t('common.node', 'Node'),
cell: ({ getValue }) => ( cell: ({ row }) => {
<span className="text-muted-foreground">{getValue<string>()}</span> const vm = row.original;
), const label =
(vm.server_id != null ? serverNameById.get(vm.server_id) : undefined) ?? vm.node;
return <span className="text-muted-foreground">{label}</span>;
},
size: 100, size: 100,
}, },
{ {
@@ -809,7 +891,7 @@ export default function InstancesPage() {
}, },
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[t, deployTasks] [t, deployTasks, overlayByVm, bulkOverlayByVm, bulkActionLabel, serverNameById, nodeLabel]
); );
// ==================== TanStack Table ==================== // ==================== TanStack Table ====================
@@ -910,7 +992,7 @@ export default function InstancesPage() {
<SelectContent> <SelectContent>
<SelectItem value="all">{t('instances.all_nodes', 'All nodes')}</SelectItem> <SelectItem value="all">{t('instances.all_nodes', 'All nodes')}</SelectItem>
{uniqueNodes.map((n) => ( {uniqueNodes.map((n) => (
<SelectItem key={n} value={n}>{n}</SelectItem> <SelectItem key={n} value={n}>{nodeLabel.get(n) ?? n}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -923,6 +1005,9 @@ export default function InstancesPage() {
<Button variant="outline" size="sm" onClick={() => handleBulk('start')} disabled={bulkOp.isPending}> <Button variant="outline" size="sm" onClick={() => handleBulk('start')} disabled={bulkOp.isPending}>
<Play className="mr-1 h-3 w-3" /> Start <Play className="mr-1 h-3 w-3" /> Start
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => handleBulk('restart')} disabled={bulkOp.isPending}>
<RotateCcw className="mr-1 h-3 w-3" /> {t('common.restart', 'Перезагрузка')}
</Button>
<Button variant="outline" size="sm" onClick={() => handleBulk('stop')} disabled={bulkOp.isPending}> <Button variant="outline" size="sm" onClick={() => handleBulk('stop')} disabled={bulkOp.isPending}>
<Square className="mr-1 h-3 w-3" /> Stop <Square className="mr-1 h-3 w-3" /> Stop
</Button> </Button>
@@ -935,6 +1020,31 @@ export default function InstancesPage() {
</div> </div>
)} )}
{/* Bulk operation progress (tracked like instance creation) */}
{bulkTasks.map((task) => {
const processed = task.completed + task.failed;
const isDone = task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled';
return (
<div key={task.id} className="flex items-center gap-3 rounded-lg border bg-muted/50 p-2">
{isDone ? (
<Power className={`h-4 w-4 ${task.failed > 0 ? 'text-amber-500' : 'text-green-500'}`} />
) : (
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
)}
<span className="text-sm font-medium">{bulkActionLabel(task.action)}</span>
<InlineProgress value={processed} max={task.total} />
<span className="text-xs tabular-nums text-muted-foreground">
{processed}/{task.total}
</span>
{task.failed > 0 && (
<span className="text-xs text-red-500">
{task.failed} {t('instances.bulk_failed', 'с ошибкой')}
</span>
)}
</div>
);
})}
{/* Table */} {/* Table */}
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">

View File

@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tansta
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { getTasksWebSocket } from '@/lib/websocket'; import { getTasksWebSocket } from '@/lib/websocket';
import { useBulkTasksStore, type BulkTask, type BulkTaskResult } from '@/stores/bulk-tasks-store';
import type { VMInstance, Snapshot, VMConfig, DiskInfo } from '@/types'; import type { VMInstance, Snapshot, VMConfig, DiskInfo } from '@/types';
// ==================== Query Keys ==================== // ==================== Query Keys ====================
@@ -488,10 +489,16 @@ interface BulkItem {
node: string; node: string;
} }
interface BulkOperationResponse {
success: boolean;
task_id: number;
message: string;
}
export function useBulkOperation() { export function useBulkOperation() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation<BulkOperationResponse, Error, { action: string; items: BulkItem[] }>({
mutationFn: (body: { action: string; items: BulkItem[] }) => mutationFn: (body) =>
apiClient.post('/proxmox/api/bulk-operation', body), apiClient.post('/proxmox/api/bulk-operation', body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all }); qc.invalidateQueries({ queryKey: vmKeys.all });
@@ -500,6 +507,45 @@ export function useBulkOperation() {
}); });
} }
/**
* Subscribes to WebSocket `task_update` events for bulk operations and patches
* the bulk-tasks store with live progress (completed/failed/results), so the
* instances list can show per-row status overlays and a progress bar — the same
* way deploy tasks surface instance creation. Only tasks started in this session
* (registered via the store's addTask) are tracked. When a task finishes it
* refetches the VM list and auto-clears after a short delay.
*/
export function useBulkTasksSync() {
const qc = useQueryClient();
const updateTask = useBulkTasksStore((s) => s.updateTask);
const removeTask = useBulkTasksStore((s) => s.removeTask);
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const unsub = ws.onType('task_update', (data: unknown) => {
const task = (data as { task?: {
kind?: string; id: number; status: string;
total_items: number; completed_items: number; failed_items: number;
results?: BulkTaskResult[];
} }).task;
if (!task || task.kind !== 'bulk') return;
updateTask(task.id, {
status: task.status as BulkTask['status'],
total: task.total_items,
completed: task.completed_items,
failed: task.failed_items,
results: task.results ?? [],
});
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
setTimeout(() => removeTask(task.id), 4000);
}
});
return () => unsub();
}, [qc, updateTask, removeTask]);
}
// ==================== Disk Resize ==================== // ==================== Disk Resize ====================
export function useResizeDisk(serverId: number, vmid: number, type: string, node: string) { export function useResizeDisk(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient(); const qc = useQueryClient();

View File

@@ -257,6 +257,12 @@
"destroy": "Destroy", "destroy": "Destroy",
"task_activity": "Task Activity", "task_activity": "Task Activity",
"status_creating": "Creating", "status_creating": "Creating",
"status_starting": "Starting",
"status_stopping": "Stopping",
"status_restarting": "Restarting",
"status_shutting_down": "Shutting down",
"status_deleting": "Deleting",
"bulk_failed": "failed",
"locked_hint": "Locked by Proxmox — operation in progress", "locked_hint": "Locked by Proxmox — operation in progress",
"vmid_pending": "pending", "vmid_pending": "pending",
"no_snapshots": "No snapshots", "no_snapshots": "No snapshots",

View File

@@ -257,6 +257,12 @@
"destroy": "Уничтожить", "destroy": "Уничтожить",
"task_activity": "Активность задач", "task_activity": "Активность задач",
"status_creating": "Создание", "status_creating": "Создание",
"status_starting": "Запуск",
"status_stopping": "Остановка",
"status_restarting": "Перезагрузка",
"status_shutting_down": "Выключение",
"status_deleting": "Удаление",
"bulk_failed": "с ошибкой",
"locked_hint": "Заблокировано Proxmox — операция выполняется", "locked_hint": "Заблокировано Proxmox — операция выполняется",
"vmid_pending": "ожидание", "vmid_pending": "ожидание",
"no_snapshots": "Нет снимков", "no_snapshots": "Нет снимков",

View File

@@ -0,0 +1,56 @@
import { create } from 'zustand';
export interface BulkTaskItemRef {
server_id: number;
vmid: number;
name: string;
}
export interface BulkTaskResult {
server_id: number;
vmid: number;
name?: string;
success: boolean;
message?: string;
}
export interface BulkTask {
id: number;
action: string; // start | stop | restart | shutdown | delete
items: BulkTaskItemRef[];
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
total: number;
completed: number;
failed: number;
results: BulkTaskResult[];
created_at: number;
}
interface BulkTasksState {
tasks: BulkTask[];
addTask: (task: Omit<BulkTask, 'created_at'>) => void;
updateTask: (id: number, update: Partial<BulkTask>) => void;
removeTask: (id: number) => void;
}
// Ephemeral (not persisted): only tracks bulk operations started in the
// current session so the instances list can show live progress like creation.
export const useBulkTasksStore = create<BulkTasksState>((set) => ({
tasks: [],
addTask: (task) =>
set((state) => ({
tasks: [
...state.tasks.filter((t) => t.id !== task.id),
{ ...task, created_at: Date.now() },
],
})),
updateTask: (id, update) =>
set((state) => ({
// Only patch tasks we already track (started in this session).
tasks: state.tasks.map((t) => (t.id === id ? { ...t, ...update } : t)),
})),
removeTask: (id) =>
set((state) => ({
tasks: state.tasks.filter((t) => t.id !== id),
})),
}));