From 5c028cdc113a8aaea77546f6a2c032234f21157b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?=
Date: Sun, 23 Nov 2025 19:08:10 +0100
Subject: [PATCH] feat: implement Phase 2 - Process Groups Management
Features added:
- Group-based process organization with collapsible cards
- Batch operations for groups (Start All, Stop All, Restart All)
- Group statistics display (running, stopped, fatal counts)
- Dedicated /groups page for group-centric management
- View toggle in /processes page (Flat view | Grouped view)
Implementation details:
- Created group API routes: /api/supervisor/groups/[name]/{start,stop,restart}
- Added React Query hooks: useStartProcessGroup, useStopProcessGroup, useRestartProcessGroup
- Created components: GroupCard, GroupView, GroupSelector
- Updated Navbar with Groups navigation link
- Integrated grouped view in processes page with toggle
Phase 2 complete (6-8 hours estimated)
---
NEXT_STEPS.md | 886 ++++++++++++++++++
.../supervisor/groups/[name]/restart/route.ts | 35 +
.../supervisor/groups/[name]/start/route.ts | 30 +
.../supervisor/groups/[name]/stop/route.ts | 30 +
app/groups/page.tsx | 80 ++
app/processes/page.tsx | 17 +-
components/groups/GroupCard.tsx | 132 +++
components/groups/GroupSelector.tsx | 37 +
components/groups/GroupView.tsx | 35 +
components/layout/Navbar.tsx | 1 +
lib/hooks/useSupervisor.ts | 86 ++
11 files changed, 1365 insertions(+), 4 deletions(-)
create mode 100644 NEXT_STEPS.md
create mode 100644 app/api/supervisor/groups/[name]/restart/route.ts
create mode 100644 app/api/supervisor/groups/[name]/start/route.ts
create mode 100644 app/api/supervisor/groups/[name]/stop/route.ts
create mode 100644 app/groups/page.tsx
create mode 100644 components/groups/GroupCard.tsx
create mode 100644 components/groups/GroupSelector.tsx
create mode 100644 components/groups/GroupView.tsx
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 ? (
+ ) : 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}`);
+ },
+ });
+}