diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md new file mode 100644 index 0000000..b83cb1e --- /dev/null +++ b/NEXT_STEPS.md @@ -0,0 +1,886 @@ +# Supervisor UI - Implementation Guide for Phases 2-12 + +## ✅ Phase 1 Complete: Log Viewer +- Real-time log viewing with syntax highlighting +- Play/pause controls and auto-scroll +- Search and filtering +- Download and clear logs +- Process selector with stdout/stderr switching + +--- + +## 📋 Remaining Implementation Plan + +### **Phase 2: Process Groups** (6-8 hours) + +#### Files to Create: + +**1. components/groups/GroupCard.tsx** +```typescript +'use client'; +import { ProcessInfo } from '@/lib/supervisor/types'; +// Card showing group with expandable process list +// Group-level start/stop/restart buttons +// Group statistics (X running, Y stopped, Z fatal) +``` + +**2. components/groups/GroupView.tsx** +```typescript +'use client'; +// Container for displaying processes grouped by group name +// Collapsible sections for each group +// Uses GroupCard for each group +``` + +**3. components/groups/GroupSelector.tsx** +```typescript +'use client'; +// Toggle button: Flat View | Grouped View +// Updates state to switch between views +``` + +**4. app/groups/page.tsx** +```typescript +'use client'; +// Dedicated page for group-centric management +// Shows all groups with their processes +// Group-level actions prominently displayed +``` + +#### API Routes to Create: + +**5. app/api/supervisor/groups/[name]/start/route.ts** +```typescript +import { createSupervisorClient } from '@/lib/supervisor/client'; +export async function POST(request, { params }) { + const { name } = await params; + const body = await request.json().catch(() => ({})); + const client = createSupervisorClient(); + const results = await client.startProcessGroup(name, body.wait ?? true); + return NextResponse.json({ success: true, results }); +} +``` + +**6. app/api/supervisor/groups/[name]/stop/route.ts** - Same as start, call `stopProcessGroup` + +**7. app/api/supervisor/groups/[name]/restart/route.ts** +```typescript +// Stop then start the group +const results = await client.stopProcessGroup(name, true); +await client.startProcessGroup(name, true); +``` + +#### Hooks to Add (lib/hooks/useSupervisor.ts): + +```typescript +export function useStartProcessGroup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => + fetch(`/api/supervisor/groups/${name}/start`, { + method: 'POST', + body: JSON.stringify({ wait }), + }).then(r => r.json()), + onSuccess: () => { + toast.success('Process group started'); + queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); + }, + }); +} + +// Add useStopProcessGroup and useRestartProcessGroup similarly +``` + +#### Update Existing Files: + +**8. app/processes/page.tsx** - Add view toggle button and conditional rendering + +--- + +### **Phase 3: Batch Operations** (4-6 hours) + +#### Files to Create: + +**1. components/process/BatchActions.tsx** +```typescript +'use client'; +// Toolbar that appears when processes are selected +// Shows: "X selected" + Start All | Stop All | Restart All buttons +// Position: Fixed at bottom or floating +``` + +**2. components/process/ProcessSelector.tsx** +```typescript +'use client'; +// Checkbox component for ProcessCard +// Manages selection state +// "Select All" checkbox for bulk selection +``` + +#### API Routes to Create: + +**3. app/api/supervisor/processes/start-all/route.ts** +```typescript +export async function POST(request) { + const body = await request.json().catch(() => ({})); + const client = createSupervisorClient(); + const results = await client.startAllProcesses(body.wait ?? true); + return NextResponse.json({ success: true, results }); +} +``` + +**4. app/api/supervisor/processes/stop-all/route.ts** - Similar, use `stopAllProcesses` + +**5. app/api/supervisor/processes/restart-all/route.ts** +```typescript +// Stop all, then start all +await client.stopAllProcesses(true); +const results = await client.startAllProcesses(true); +``` + +#### Hooks to Add: + +```typescript +export function useStartAllProcesses() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (wait: boolean = true) => + fetch('/api/supervisor/processes/start-all', { + method: 'POST', + body: JSON.stringify({ wait }), + }).then(r => r.json()), + onSuccess: () => { + toast.success('All processes started'); + queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); + }, + }); +} + +// Add useStopAllProcesses and useRestartAllProcesses +``` + +#### Update ProcessCard.tsx: +- Add checkbox in corner +- Pass selection state from parent +- Add onClick handler to toggle selection + +--- + +### **Phase 4: Configuration Management** (8-10 hours) + +#### Files to Create: + +**1. components/config/ConfigViewer.tsx** +```typescript +'use client'; +import { useConfigInfo } from '@/lib/hooks/useSupervisor'; +// Displays all process configurations in a table +// Columns: Name, Group, Command, Directory, Autostart, etc. +// Sortable columns +``` + +**2. components/config/ConfigTable.tsx** +```typescript +// Table component with sorting +// Shows all config fields from ConfigInfo type +``` + +**3. components/config/ProcessGroupForm.tsx** +```typescript +'use client'; +// Form to add a new process group +// Input: group name +// Calls useAddProcessGroup hook +``` + +**4. components/config/ReloadConfigButton.tsx** +```typescript +'use client'; +import { useReloadConfig } from '@/lib/hooks/useSupervisor'; +// Button that reloads configuration +// Shows confirmation dialog +// Displays results: added, changed, removed groups +``` + +**5. components/config/DangerZone.tsx** +```typescript +'use client'; +// Red-bordered section at bottom of config page +// Contains: Shutdown Supervisor, Restart Supervisor buttons +// Strong confirmation dialogs (type "CONFIRM") +``` + +#### API Routes to Create: + +**6. app/api/supervisor/config/route.ts** +```typescript +export async function GET() { + const client = createSupervisorClient(); + const config = await client.getAllConfigInfo(); + return NextResponse.json(config); +} +``` + +**7. app/api/supervisor/config/reload/route.ts** +```typescript +export async function POST() { + const client = createSupervisorClient(); + const result = await client.reloadConfig(); + return NextResponse.json(result); // { added, changed, removed } +} +``` + +**8. app/api/supervisor/groups/add/route.ts** +```typescript +export async function POST(request) { + const { name } = await request.json(); + const client = createSupervisorClient(); + const result = await client.addProcessGroup(name); + return NextResponse.json({ success: result }); +} +``` + +**9. app/api/supervisor/groups/[name]/route.ts** +```typescript +export async function DELETE(request, { params }) { + const { name } = await params; + const client = createSupervisorClient(); + const result = await client.removeProcessGroup(name); + return NextResponse.json({ success: result }); +} +``` + +**10. app/api/supervisor/shutdown/route.ts** +```typescript +export async function POST() { + const client = createSupervisorClient(); + const result = await client.shutdown(); + return NextResponse.json({ success: result }); +} +``` + +**11. app/api/supervisor/restart/route.ts** - Similar, use `client.restart()` + +#### Hooks to Add: + +```typescript +export function useConfigInfo() { + return useQuery({ + queryKey: [...supervisorKeys.all, 'config'], + queryFn: () => fetch('/api/supervisor/config').then(r => r.json()), + refetchInterval: 30000, // 30 seconds + }); +} + +export function useReloadConfig() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => + fetch('/api/supervisor/config/reload', { method: 'POST' }).then(r => r.json()), + onSuccess: (data) => { + toast.success(`Config reloaded: ${data.added.length} added, ${data.changed.length} changed, ${data.removed.length} removed`); + queryClient.invalidateQueries({ queryKey: supervisorKeys.all }); + }, + }); +} + +// Add useAddProcessGroup, useRemoveProcessGroup, useShutdownSupervisor, useRestartSupervisor +``` + +#### Update app/config/page.tsx: +- Replace placeholder with full implementation +- Use ConfigViewer, ReloadConfigButton, ProcessGroupForm +- Add DangerZone at bottom + +--- + +### **Phase 5: Charts & Metrics** (8-12 hours) + +#### Files to Create: + +**1. lib/stores/metricsStore.ts** +```typescript +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface MetricsState { + processHistory: Record; + addSnapshot: (processes: ProcessInfo[]) => void; + clearHistory: () => void; +} + +// Store process snapshots every X seconds +// Keep last N entries (configurable) +// Persist to localStorage +``` + +**2. components/charts/UptimeChart.tsx** +```typescript +'use client'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'; +// Show uptime over time for selected process +// X-axis: Time, Y-axis: Uptime (seconds) +``` + +**3. components/charts/RestartFrequencyChart.tsx** +```typescript +'use client'; +import { BarChart, Bar, XAxis, YAxis } from 'recharts'; +// Show number of restarts per process +// X-axis: Process name, Y-axis: Restart count +``` + +**4. components/charts/StateDistributionChart.tsx** +```typescript +'use client'; +import { PieChart, Pie, Cell } from 'recharts'; +// Pie chart showing: Running, Stopped, Fatal +// Color-coded by state +``` + +**5. components/charts/EventTimeline.tsx** +```typescript +'use client'; +// Timeline showing state changes +// Events: started, stopped, restarted, fatal +// Scrollable timeline with timestamps +``` + +**6. app/metrics/page.tsx** +```typescript +'use client'; +import { useMetricsStore } from '@/lib/stores/metricsStore'; +// Dashboard with all charts +// Time range selector (1h, 24h, 7d, 30d) +// Process selector for uptime chart +``` + +#### Update Navbar: +- Add "Metrics" link to navigation + +#### Data Collection: +Add to Providers.tsx or create useMetricsCollection hook: +```typescript +useEffect(() => { + const interval = setInterval(() => { + const processes = queryClient.getQueryData(supervisorKeys.processes()); + if (processes) { + metricsStore.addSnapshot(processes); + } + }, 60000); // Every minute + return () => clearInterval(interval); +}, []); +``` + +--- + +### **Phase 6: Search & Filtering** (2-3 hours) + +#### Files to Create: + +**1. components/process/ProcessSearch.tsx** +```typescript +'use client'; +// Search input similar to LogSearch +// Filters processes by name or group +``` + +**2. components/process/ProcessFilters.tsx** +```typescript +'use client'; +// Dropdown or chips for filtering +// Options: State (running/stopped/fatal), Group +// Multiple filters can be active +``` + +#### Update app/processes/page.tsx: +```typescript +const [searchTerm, setSearchTerm] = useState(''); +const [stateFilter, setStateFilter] = useState([]); +const [groupFilter, setGroupFilter] = useState([]); + +const filteredProcesses = processes?.filter(proc => { + if (searchTerm && !proc.name.toLowerCase().includes(searchTerm.toLowerCase())) return false; + if (stateFilter.length > 0 && !stateFilter.includes(proc.state)) return false; + if (groupFilter.length > 0 && !groupFilter.includes(proc.group)) return false; + return true; +}); +``` + +#### Persist Filters: +```typescript +useEffect(() => { + localStorage.setItem('processFilters', JSON.stringify({ searchTerm, stateFilter, groupFilter })); +}, [searchTerm, stateFilter, groupFilter]); +``` + +--- + +### **Phase 7: Signal Operations** (3-4 hours) + +#### Files to Create: + +**1. components/process/SignalSender.tsx** +```typescript +'use client'; +// Modal dialog with signal dropdown +// Common signals: HUP, USR1, USR2, TERM, KILL, INT +// Confirmation for TERM and KILL +// Input for custom signal +``` + +**2. components/process/SignalButton.tsx** +```typescript +'use client'; +// Button that opens SignalSender modal +// Icon: Zap or Command +``` + +#### API Routes to Create: + +**3. app/api/supervisor/processes/[name]/signal/route.ts** +```typescript +export async function POST(request, { params }) { + const { name } = await params; + const { signal } = await request.json(); + const client = createSupervisorClient(); + const result = await client.signalProcess(name, signal); + return NextResponse.json({ success: result }); +} +``` + +**4. app/api/supervisor/groups/[name]/signal/route.ts** - Use `signalProcessGroup` + +**5. app/api/supervisor/processes/signal-all/route.ts** - Use `signalAllProcesses` + +#### Hooks to Add: + +```typescript +export function useSignalProcess() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ name, signal }: { name: string; signal: string }) => + fetch(`/api/supervisor/processes/${name}/signal`, { + method: 'POST', + body: JSON.stringify({ signal }), + }).then(r => r.json()), + onSuccess: (_, { name, signal }) => { + toast.success(`Signal ${signal} sent to ${name}`); + queryClient.invalidateQueries({ queryKey: supervisorKeys.process(name) }); + }, + }); +} +``` + +#### Update ProcessCard.tsx: +- Add signal button in actions section +- Use SignalButton component + +--- + +### **Phase 8: Process Stdin** (2-3 hours) + +#### Files to Create: + +**1. components/process/StdinInput.tsx** +```typescript +'use client'; +// Modal with textarea for multi-line input +// Send button +// Confirmation before sending +``` + +**2. components/process/StdinButton.tsx** +```typescript +'use client'; +// Button that opens StdinInput modal +// Icon: Terminal or Keyboard +``` + +#### API Route: + +**3. app/api/supervisor/processes/[name]/stdin/route.ts** +```typescript +export async function POST(request, { params }) { + const { name } = await params; + const { chars } = await request.json(); + const client = createSupervisorClient(); + const result = await client.sendProcessStdin(name, chars); + return NextResponse.json({ success: result }); +} +``` + +#### Hook: + +```typescript +export function useSendProcessStdin() { + return useMutation({ + mutationFn: ({ name, chars }: { name: string; chars: string }) => + fetch(`/api/supervisor/processes/${name}/stdin`, { + method: 'POST', + body: JSON.stringify({ chars }), + }).then(r => r.json()), + onSuccess: (_, { name }) => { + toast.success(`Input sent to ${name}`); + }, + }); +} +``` + +#### Update ProcessCard.tsx: +- Add stdin button (collapsed/advanced section) + +--- + +### **Phase 9: Keyboard Shortcuts** (3-4 hours) + +#### Installation: + +```bash +pnpm add react-hotkeys-hook +``` + +#### Files to Create: + +**1. lib/hooks/useKeyboardShortcuts.ts** +```typescript +'use client'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useRouter } from 'next/navigation'; + +export function useGlobalKeyboardShortcuts() { + const router = useRouter(); + + useHotkeys('/', () => { /* Focus search */ }, { preventDefault: true }); + useHotkeys('r', () => { /* Refresh page */ }); + useHotkeys('g,h', () => router.push('/')); + useHotkeys('g,p', () => router.push('/processes')); + useHotkeys('g,l', () => router.push('/logs')); + useHotkeys('g,c', () => router.push('/config')); + useHotkeys('?', () => { /* Open shortcuts modal */ }); +} +``` + +**2. components/ui/KeyboardShortcutsModal.tsx** +```typescript +'use client'; +// Modal showing all keyboard shortcuts +// Organized by category +// Opened with "?" key +``` + +**3. components/ui/KeyboardShortcutBadge.tsx** +```typescript +// Small badge showing keyboard shortcut +// Used in tooltips +// Example: r +``` + +#### Update app/layout.tsx: +```typescript +import { useGlobalKeyboardShortcuts } from '@/lib/hooks/useKeyboardShortcuts'; + +// Inside layout component (need to make it a client component wrapper) +useGlobalKeyboardShortcuts(); +``` + +--- + +### **Phase 10: Supervisor Control** (2-3 hours) + +Already covered in Phase 4 (DangerZone component). + +Implement strong confirmations: +```typescript +const handleShutdown = () => { + const confirmation = prompt('Type "CONFIRM" to shutdown Supervisor:'); + if (confirmation !== 'CONFIRM') { + toast.error('Shutdown cancelled'); + return; + } + shutdownMutation.mutate(); +}; +``` + +--- + +### **Phase 11: WebSocket/SSE Real-time** (12-16 hours) + +#### Recommended Approach: Server-Sent Events (SSE) + +#### Files to Create: + +**1. app/api/supervisor/events/route.ts** +```typescript +export async function GET() { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const client = createSupervisorClient(); + let lastState = ''; + + const interval = setInterval(async () => { + try { + const processes = await client.getAllProcessInfo(); + const state = JSON.stringify(processes); + + if (state !== lastState) { + lastState = state; + const data = `data: ${state}\n\n`; + controller.enqueue(encoder.encode(data)); + } + } catch (error) { + console.error('SSE error:', error); + } + }, 1000); // Poll every second + + // Cleanup on close + return () => clearInterval(interval); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); +} +``` + +**2. lib/hooks/useSupervisorSSE.ts** +```typescript +'use client'; +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +export function useSupervisorSSE() { + const queryClient = useQueryClient(); + const [connected, setConnected] = useState(false); + + useEffect(() => { + const eventSource = new EventSource('/api/supervisor/events'); + + eventSource.onopen = () => { + setConnected(true); + }; + + eventSource.onmessage = (event) => { + const processes = JSON.parse(event.data); + queryClient.setQueryData(supervisorKeys.processes(), processes); + }; + + eventSource.onerror = () => { + setConnected(false); + eventSource.close(); + }; + + return () => eventSource.close(); + }, []); + + return { connected }; +} +``` + +**3. components/ui/ConnectionStatus.tsx** +```typescript +'use client'; +import { useSupervisorSSE } from '@/lib/hooks/useSupervisorSSE'; +// Green dot: connected +// Yellow dot: connecting +// Red dot: disconnected +``` + +#### Update Providers.tsx: +```typescript +export function Providers({ children }) { + useSupervisorSSE(); // Enable SSE + + return ( + {/* ... */} + ); +} +``` + +#### Update Navbar: +- Add ConnectionStatus indicator + +--- + +### **Phase 12: Multi-Instance Support** (16-20 hours) + +#### Files to Create: + +**1. lib/stores/connectionsStore.ts** +```typescript +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface Connection { + id: string; + name: string; + host: string; + port: number; + username?: string; + password?: string; +} + +interface ConnectionsState { + connections: Connection[]; + activeConnectionId: string | null; + addConnection: (conn: Connection) => void; + removeConnection: (id: string) => void; + setActiveConnection: (id: string) => void; + getActiveConnection: () => Connection | null; +} +``` + +**2. components/instances/InstanceSwitcher.tsx** +```typescript +'use client'; +import { useConnectionsStore } from '@/lib/stores/connectionsStore'; +// Dropdown in navbar +// Shows all connections +// Click to switch active instance +``` + +**3. components/instances/ConnectionManager.tsx** +```typescript +'use client'; +// Table of all connections +// Edit, Delete, Test buttons +// Add new connection button +``` + +**4. components/instances/AddConnectionModal.tsx** +```typescript +'use client'; +// Form: Name, Host, Port, Username (optional), Password (optional) +// Test connection button +// Save button +``` + +**5. app/instances/page.tsx** +```typescript +'use client'; +// Page to manage all connections +// Uses ConnectionManager +``` + +#### Update API Routes: +All routes need to accept instance configuration. Two approaches: + +**Option A:** Pass instance ID in header +```typescript +const headers = { 'X-Instance-Id': instanceId }; +fetch('/api/supervisor/processes', { headers }); +``` + +Then in API route: +```typescript +const instanceId = request.headers.get('X-Instance-Id'); +const connection = getConnectionById(instanceId); // from store +const client = createSupervisorClient(connection); +``` + +**Option B:** Pass connection config in body (less secure) + +#### Update All Hooks: +Add optional `instanceId` parameter: +```typescript +export function useProcesses(options?: { instanceId?: string }) { + const activeInstance = useConnectionsStore(s => s.getActiveConnection()); + const instance = options?.instanceId || activeInstance?.id; + + return useQuery({ + queryKey: [...supervisorKeys.all, instance, 'processes'], + queryFn: () => fetch('/api/supervisor/processes', { + headers: { 'X-Instance-Id': instance }, + }).then(r => r.json()), + }); +} +``` + +#### Update Dashboard: +- Show overview of all instances +- Quick stats for each +- Click to view instance details + +--- + +## 🚀 Quick Start for Next Session + +1. **Start where we left off:** + ```bash + cd /home/valknar/Projects/supervisor-ui + git pull + pnpm dev + ``` + +2. **Pick a phase** from above (recommend Phase 2 or 3 next) + +3. **Follow the file-by-file instructions** - each phase is self-contained + +4. **Test as you go:** + ```bash + pnpm build # Test compilation + ``` + +5. **Commit when phase complete:** + ```bash + git add -A + git commit -m "feat: complete Phase X - [description]" + git push + ``` + +--- + +## 📊 Progress Tracking + +- [x] **Phase 1:** Log Viewer ✅ +- [ ] **Phase 2:** Process Groups (6-8h) +- [ ] **Phase 3:** Batch Operations (4-6h) +- [ ] **Phase 4:** Configuration Management (8-10h) +- [ ] **Phase 5:** Charts & Metrics (8-12h) +- [ ] **Phase 6:** Search & Filtering (2-3h) +- [ ] **Phase 7:** Signal Operations (3-4h) +- [ ] **Phase 8:** Process Stdin (2-3h) +- [ ] **Phase 9:** Keyboard Shortcuts (3-4h) +- [ ] **Phase 10:** Supervisor Control (included in Phase 4) +- [ ] **Phase 11:** WebSocket/SSE (12-16h) +- [ ] **Phase 12:** Multi-Instance (16-20h) + +**Total Remaining:** ~75 hours + +--- + +## 💡 Tips for Implementation + +1. **Work in small commits** - One feature at a time +2. **Test the build frequently** - `pnpm build` catches type errors +3. **Follow the type signatures** - TypeScript will guide you +4. **Reuse existing patterns** - Look at Phase 1 code for reference +5. **Don't over-engineer** - Implement exactly what's described +6. **Update README** - Document new features as you add them + +--- + +## 🎯 Recommended Order + +If you want to maximize value with minimal time: + +1. **Phase 6** (2-3h) - Search & Filtering → Immediate productivity boost +2. **Phase 3** (4-6h) - Batch Operations → High user value +3. **Phase 7** (3-4h) - Signal Operations → Complete process control +4. **Phase 2** (6-8h) - Process Groups → Better organization +5. **Phase 4** (8-10h) - Config Management → Production ready +6. **Phase 5** (8-12h) - Charts & Metrics → Visual appeal + +This gets you 80% of the value in ~40 hours instead of 75. + +--- + +**Last Updated:** November 23, 2025 +**Current Version:** 0.2.0 (Phase 1 Complete) +**Repository:** ssh://dev.pivoine.art:2222/valknar/supervisor-ui.git diff --git a/app/api/supervisor/groups/[name]/restart/route.ts b/app/api/supervisor/groups/[name]/restart/route.ts new file mode 100644 index 0000000..fc8ca02 --- /dev/null +++ b/app/api/supervisor/groups/[name]/restart/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSupervisorClient } from '@/lib/supervisor/client'; + +interface RouteParams { + params: Promise<{ name: string }>; +} + +// POST - Restart all processes in a group (stop then start) +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { name } = await params; + const body = await request.json().catch(() => ({})); + const wait = body.wait ?? true; + + const client = createSupervisorClient(); + + // Stop all processes in the group first + await client.stopProcessGroup(name, wait); + + // Then start them + const results = await client.startProcessGroup(name, wait); + + return NextResponse.json({ + success: true, + message: `Restarted process group: ${name}`, + results, + }); + } catch (error: any) { + console.error('Supervisor restart process group error:', error); + return NextResponse.json( + { error: error.message || 'Failed to restart process group' }, + { status: 500 } + ); + } +} diff --git a/app/api/supervisor/groups/[name]/start/route.ts b/app/api/supervisor/groups/[name]/start/route.ts new file mode 100644 index 0000000..f43475a --- /dev/null +++ b/app/api/supervisor/groups/[name]/start/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSupervisorClient } from '@/lib/supervisor/client'; + +interface RouteParams { + params: Promise<{ name: string }>; +} + +// POST - Start all processes in a group +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { name } = await params; + const body = await request.json().catch(() => ({})); + const wait = body.wait ?? true; + + const client = createSupervisorClient(); + const results = await client.startProcessGroup(name, wait); + + return NextResponse.json({ + success: true, + message: `Started process group: ${name}`, + results, + }); + } catch (error: any) { + console.error('Supervisor start process group error:', error); + return NextResponse.json( + { error: error.message || 'Failed to start process group' }, + { status: 500 } + ); + } +} diff --git a/app/api/supervisor/groups/[name]/stop/route.ts b/app/api/supervisor/groups/[name]/stop/route.ts new file mode 100644 index 0000000..50069b5 --- /dev/null +++ b/app/api/supervisor/groups/[name]/stop/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSupervisorClient } from '@/lib/supervisor/client'; + +interface RouteParams { + params: Promise<{ name: string }>; +} + +// POST - Stop all processes in a group +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { name } = await params; + const body = await request.json().catch(() => ({})); + const wait = body.wait ?? true; + + const client = createSupervisorClient(); + const results = await client.stopProcessGroup(name, wait); + + return NextResponse.json({ + success: true, + message: `Stopped process group: ${name}`, + results, + }); + } catch (error: any) { + console.error('Supervisor stop process group error:', error); + return NextResponse.json( + { error: error.message || 'Failed to stop process group' }, + { status: 500 } + ); + } +} diff --git a/app/groups/page.tsx b/app/groups/page.tsx new file mode 100644 index 0000000..bdea19f --- /dev/null +++ b/app/groups/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useProcesses } from '@/lib/hooks/useSupervisor'; +import { GroupView } from '@/components/groups/GroupView'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { RefreshCw, AlertCircle } from 'lucide-react'; + +export default function GroupsPage() { + const { data: processes, isLoading, isError, refetch } = useProcesses(); + + if (isError) { + return ( +
+

Process Groups

+ + + +

Failed to load processes

+

+ Could not connect to Supervisor. Please check your configuration. +

+ +
+
+
+ ); + } + + return ( +
+
+
+

Process Groups

+

+ Manage processes organized by groups with batch operations +

+
+ + +
+ + {isLoading ? ( +
+ {[1, 2].map((i) => ( + + +
+
+ {[1, 2, 3].map((j) => ( +
+ ))} +
+
+
+ ))} +
+ ) : processes && processes.length > 0 ? ( + + ) : ( + + +

No processes found

+
+
+ )} +
+ ); +} diff --git a/app/processes/page.tsx b/app/processes/page.tsx index 2728c36..381563b 100644 --- a/app/processes/page.tsx +++ b/app/processes/page.tsx @@ -1,11 +1,15 @@ 'use client'; +import { useState } from 'react'; 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 { Button } from '@/components/ui/button'; export default function ProcessesPage() { + const [viewMode, setViewMode] = useState<'flat' | 'grouped'>('flat'); const { data: processes, isLoading, isError, refetch } = useProcesses(); if (isLoading) { @@ -49,16 +53,21 @@ export default function ProcessesPage() { {processes?.length ?? 0} processes configured

- +
+ + +
{processes && processes.length === 0 ? (

No processes configured

+ ) : viewMode === 'grouped' ? ( + ) : (
{processes?.map((process) => ( diff --git a/components/groups/GroupCard.tsx b/components/groups/GroupCard.tsx new file mode 100644 index 0000000..cc60ba5 --- /dev/null +++ b/components/groups/GroupCard.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useState } from 'react'; +import { ProcessInfo, ProcessState, ProcessStateCode } from '@/lib/supervisor/types'; +import { useStartProcessGroup, useStopProcessGroup, useRestartProcessGroup } from '@/lib/hooks/useSupervisor'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { ProcessCard } from '@/components/process/ProcessCard'; +import { ChevronDown, ChevronUp, Play, Square, RotateCw } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; + +interface GroupCardProps { + groupName: string; + processes: ProcessInfo[]; +} + +export function GroupCard({ groupName, processes }: GroupCardProps) { + const [isExpanded, setIsExpanded] = useState(true); + + const startGroupMutation = useStartProcessGroup(); + const stopGroupMutation = useStopProcessGroup(); + const restartGroupMutation = useRestartProcessGroup(); + + const isLoading = startGroupMutation.isPending || stopGroupMutation.isPending || restartGroupMutation.isPending; + + // Calculate statistics + const stats = processes.reduce( + (acc, proc) => { + if (proc.state === ProcessState.RUNNING) acc.running++; + else if (proc.state === ProcessState.STOPPED || proc.state === ProcessState.EXITED) acc.stopped++; + else if (proc.state === ProcessState.FATAL) acc.fatal++; + return acc; + }, + { running: 0, stopped: 0, fatal: 0, total: processes.length } + ); + + const handleStart = () => { + startGroupMutation.mutate({ name: groupName }); + }; + + const handleStop = () => { + stopGroupMutation.mutate({ name: groupName }); + }; + + const handleRestart = () => { + restartGroupMutation.mutate({ name: groupName }); + }; + + return ( + + +
+
+ +
+ {groupName} +
+ + Total: {stats.total} + + + Running: {stats.running} + + + Stopped: {stats.stopped} + + {stats.fatal > 0 && ( + + Fatal: {stats.fatal} + + )} +
+
+
+ +
+ + + +
+
+
+ + {isExpanded && ( + +
+ {processes.map((process) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/components/groups/GroupSelector.tsx b/components/groups/GroupSelector.tsx new file mode 100644 index 0000000..fdcf3fc --- /dev/null +++ b/components/groups/GroupSelector.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { LayoutGrid, List } from 'lucide-react'; + +interface GroupSelectorProps { + viewMode: 'flat' | 'grouped'; + onViewModeChange: (mode: 'flat' | 'grouped') => void; +} + +export function GroupSelector({ viewMode, onViewModeChange }: GroupSelectorProps) { + return ( +
+ View: +
+ + +
+
+ ); +} diff --git a/components/groups/GroupView.tsx b/components/groups/GroupView.tsx new file mode 100644 index 0000000..ac8e38a --- /dev/null +++ b/components/groups/GroupView.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { ProcessInfo } from '@/lib/supervisor/types'; +import { GroupCard } from './GroupCard'; + +interface GroupViewProps { + processes: ProcessInfo[]; +} + +export function GroupView({ processes }: GroupViewProps) { + // Group processes by their group name + const groupedProcesses = processes.reduce((acc, process) => { + const groupName = process.group; + if (!acc[groupName]) { + acc[groupName] = []; + } + acc[groupName].push(process); + return acc; + }, {} as Record); + + // Sort groups alphabetically + const sortedGroups = Object.keys(groupedProcesses).sort(); + + return ( +
+ {sortedGroups.map((groupName) => ( + + ))} +
+ ); +} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 4ce33b5..22e710e 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -11,6 +11,7 @@ import { cn } from '@/lib/utils/cn'; const navItems = [ { href: '/', label: 'Dashboard' }, { href: '/processes', label: 'Processes' }, + { href: '/groups', label: 'Groups' }, { href: '/logs', label: 'Logs' }, { href: '/config', label: 'Configuration' }, ]; diff --git a/lib/hooks/useSupervisor.ts b/lib/hooks/useSupervisor.ts index d955963..41dea2a 100644 --- a/lib/hooks/useSupervisor.ts +++ b/lib/hooks/useSupervisor.ts @@ -284,3 +284,89 @@ export function useClearAllLogs() { }, }); } + +// Process Group Management + +async function startProcessGroup(name: string, wait: boolean = true): Promise<{ success: boolean; message: string; results: any[] }> { + const response = await fetch(`/api/supervisor/groups/${encodeURIComponent(name)}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wait }), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start process group'); + } + return response.json(); +} + +async function stopProcessGroup(name: string, wait: boolean = true): Promise<{ success: boolean; message: string; results: any[] }> { + const response = await fetch(`/api/supervisor/groups/${encodeURIComponent(name)}/stop`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wait }), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to stop process group'); + } + return response.json(); +} + +async function restartProcessGroup(name: string, wait: boolean = true): Promise<{ success: boolean; message: string; results: any[] }> { + const response = await fetch(`/api/supervisor/groups/${encodeURIComponent(name)}/restart`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wait }), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to restart process group'); + } + return response.json(); +} + +export function useStartProcessGroup() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => startProcessGroup(name, wait), + onSuccess: (data) => { + toast.success(data.message); + queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); + }, + onError: (error: Error) => { + toast.error(`Failed to start process group: ${error.message}`); + }, + }); +} + +export function useStopProcessGroup() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => stopProcessGroup(name, wait), + onSuccess: (data) => { + toast.success(data.message); + queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); + }, + onError: (error: Error) => { + toast.error(`Failed to stop process group: ${error.message}`); + }, + }); +} + +export function useRestartProcessGroup() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => restartProcessGroup(name, wait), + onSuccess: (data) => { + toast.success(data.message); + queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); + }, + onError: (error: Error) => { + toast.error(`Failed to restart process group: ${error.message}`); + }, + }); +}