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:
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Нет снимков",
|
||||||
|
|||||||
56
frontend/src/stores/bulk-tasks-store.ts
Normal file
56
frontend/src/stores/bulk-tasks-store.ts
Normal 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),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user