From 236786cb315a00fd84ec68ea6ce427fed42147fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 23 Nov 2025 19:14:04 +0100 Subject: [PATCH] feat: implement Phase 3 - Batch Operations Features added: - Multi-select functionality for processes with checkboxes - Floating BatchActions toolbar appears when processes are selected - Batch operations: Start Selected, Stop Selected, Restart Selected - Select All / Deselect All button in processes page - Visual feedback with ring indicator on selected cards - Click card to toggle selection, buttons prevent card selection Implementation details: - Created batch API routes: /api/supervisor/processes/{start-all,stop-all,restart-all} - Added React Query hooks: useStartAllProcesses, useStopAllProcesses, useRestartAllProcesses - Created BatchActions component with floating toolbar - Enhanced ProcessCard with optional selection mode (isSelected, onSelectionChange props) - Updated processes page with selection state management - Checkbox prevents event bubbling to avoid conflicts with action buttons UX improvements: - Selected cards show primary ring with offset - BatchActions toolbar slides up from bottom - Selection count displayed in toolbar - Clear selection with X button or after batch action completes Phase 3 complete (4-6 hours estimated) --- .../supervisor/processes/restart-all/route.ts | 30 +++++ .../supervisor/processes/start-all/route.ts | 25 ++++ .../supervisor/processes/stop-all/route.ts | 25 ++++ app/processes/page.tsx | 61 +++++++++- components/process/BatchActions.tsx | 110 ++++++++++++++++++ components/process/ProcessCard.tsx | 42 ++++++- lib/hooks/useSupervisor.ts | 86 ++++++++++++++ 7 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 app/api/supervisor/processes/restart-all/route.ts create mode 100644 app/api/supervisor/processes/start-all/route.ts create mode 100644 app/api/supervisor/processes/stop-all/route.ts create mode 100644 components/process/BatchActions.tsx diff --git a/app/api/supervisor/processes/restart-all/route.ts b/app/api/supervisor/processes/restart-all/route.ts new file mode 100644 index 0000000..7f1d031 --- /dev/null +++ b/app/api/supervisor/processes/restart-all/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSupervisorClient } from '@/lib/supervisor/client'; + +// POST - Restart all processes (stop then start) +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const wait = body.wait ?? true; + + const client = createSupervisorClient(); + + // Stop all processes first + await client.stopAllProcesses(wait); + + // Then start them + const results = await client.startAllProcesses(wait); + + return NextResponse.json({ + success: true, + message: 'Restarted all processes', + results, + }); + } catch (error: any) { + console.error('Supervisor restart all processes error:', error); + return NextResponse.json( + { error: error.message || 'Failed to restart all processes' }, + { status: 500 } + ); + } +} diff --git a/app/api/supervisor/processes/start-all/route.ts b/app/api/supervisor/processes/start-all/route.ts new file mode 100644 index 0000000..40dbb8c --- /dev/null +++ b/app/api/supervisor/processes/start-all/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSupervisorClient } from '@/lib/supervisor/client'; + +// POST - Start all processes +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const wait = body.wait ?? true; + + const client = createSupervisorClient(); + const results = await client.startAllProcesses(wait); + + return NextResponse.json({ + success: true, + message: 'Started all processes', + results, + }); + } catch (error: any) { + console.error('Supervisor start all processes error:', error); + return NextResponse.json( + { error: error.message || 'Failed to start all processes' }, + { status: 500 } + ); + } +} diff --git a/app/api/supervisor/processes/stop-all/route.ts b/app/api/supervisor/processes/stop-all/route.ts new file mode 100644 index 0000000..6291f49 --- /dev/null +++ b/app/api/supervisor/processes/stop-all/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSupervisorClient } from '@/lib/supervisor/client'; + +// POST - Stop all processes +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const wait = body.wait ?? true; + + const client = createSupervisorClient(); + const results = await client.stopAllProcesses(wait); + + return NextResponse.json({ + success: true, + message: 'Stopped all processes', + results, + }); + } catch (error: any) { + console.error('Supervisor stop all processes error:', error); + return NextResponse.json( + { error: error.message || 'Failed to stop all processes' }, + { status: 500 } + ); + } +} diff --git a/app/processes/page.tsx b/app/processes/page.tsx index 381563b..69d415d 100644 --- a/app/processes/page.tsx +++ b/app/processes/page.tsx @@ -5,13 +5,41 @@ import { useProcesses } from '@/lib/hooks/useSupervisor'; import { ProcessCard } from '@/components/process/ProcessCard'; import { GroupView } from '@/components/groups/GroupView'; import { GroupSelector } from '@/components/groups/GroupSelector'; -import { RefreshCw, AlertCircle } from 'lucide-react'; +import { BatchActions } from '@/components/process/BatchActions'; +import { RefreshCw, AlertCircle, CheckSquare } from 'lucide-react'; import { Button } from '@/components/ui/button'; export default function ProcessesPage() { const [viewMode, setViewMode] = useState<'flat' | 'grouped'>('flat'); + const [selectedProcesses, setSelectedProcesses] = useState>(new Set()); const { data: processes, isLoading, isError, refetch } = useProcesses(); + const handleSelectionChange = (processId: string, selected: boolean) => { + setSelectedProcesses((prev) => { + const newSet = new Set(prev); + if (selected) { + newSet.add(processId); + } else { + newSet.delete(processId); + } + return newSet; + }); + }; + + const handleSelectAll = () => { + if (processes) { + if (selectedProcesses.size === processes.length) { + setSelectedProcesses(new Set()); + } else { + setSelectedProcesses(new Set(processes.map((p) => `${p.group}:${p.name}`))); + } + } + }; + + const handleClearSelection = () => { + setSelectedProcesses(new Set()); + }; + if (isLoading) { return (
@@ -54,6 +82,17 @@ export default function ProcessesPage() {

+ {viewMode === 'flat' && processes && processes.length > 0 && ( + + )}
); } diff --git a/components/process/BatchActions.tsx b/components/process/BatchActions.tsx new file mode 100644 index 0000000..8bc3642 --- /dev/null +++ b/components/process/BatchActions.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { ProcessInfo } from '@/lib/supervisor/types'; +import { useStartProcess, useStopProcess, useRestartProcess } from '@/lib/hooks/useSupervisor'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Play, Square, RotateCw, X } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; + +interface BatchActionsProps { + selectedProcesses: Set; + processes: ProcessInfo[]; + onClearSelection: () => void; +} + +export function BatchActions({ selectedProcesses, processes, onClearSelection }: BatchActionsProps) { + const startMutation = useStartProcess(); + const stopMutation = useStopProcess(); + const restartMutation = useRestartProcess(); + + const isLoading = startMutation.isPending || stopMutation.isPending || restartMutation.isPending; + + const selectedCount = selectedProcesses.size; + + if (selectedCount === 0) return null; + + const handleStartSelected = async () => { + for (const processId of selectedProcesses) { + startMutation.mutate({ name: processId }); + } + onClearSelection(); + }; + + const handleStopSelected = async () => { + for (const processId of selectedProcesses) { + stopMutation.mutate({ name: processId }); + } + onClearSelection(); + }; + + const handleRestartSelected = async () => { + for (const processId of selectedProcesses) { + restartMutation.mutate(processId); + } + onClearSelection(); + }; + + return ( +
+ +
+
+
+ {selectedCount} +
+ + {selectedCount} {selectedCount === 1 ? 'process' : 'processes'} selected + +
+ +
+ +
+ + + +
+ +
+ + +
+ +
+ ); +} diff --git a/components/process/ProcessCard.tsx b/components/process/ProcessCard.tsx index bfa3f1e..81e24e9 100644 --- a/components/process/ProcessCard.tsx +++ b/components/process/ProcessCard.tsx @@ -10,9 +10,11 @@ import { cn } from '@/lib/utils/cn'; interface ProcessCardProps { process: ProcessInfo; + isSelected?: boolean; + onSelectionChange?: (processId: string, selected: boolean) => void; } -export function ProcessCard({ process }: ProcessCardProps) { +export function ProcessCard({ process, isSelected = false, onSelectionChange }: ProcessCardProps) { const startMutation = useStartProcess(); const stopMutation = useStopProcess(); const restartMutation = useRestartProcess(); @@ -25,13 +27,41 @@ export function ProcessCard({ process }: ProcessCardProps) { const handleStop = () => stopMutation.mutate({ name: fullName }); const handleRestart = () => restartMutation.mutate(fullName); + const handleCardClick = () => { + if (onSelectionChange) { + onSelectionChange(fullName, !isSelected); + } + }; + return ( - +
-
- {process.name} -

{process.group}

+
+ {onSelectionChange && ( +
+ { + e.stopPropagation(); + onSelectionChange(fullName, e.target.checked); + }} + className="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2" + /> +
+ )} +
+ {process.name} +

{process.group}

+
+
e.stopPropagation()}>