feat: implement Phase 2 - Process Groups Management
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 58s

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)
This commit is contained in:
2025-11-23 19:08:10 +01:00
parent f2d89d8333
commit 5c028cdc11
11 changed files with 1365 additions and 4 deletions

886
NEXT_STEPS.md Normal file
View File

@@ -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<string, ProcessHistoryEntry[]>;
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<ProcessStateCode[]>([]);
const [groupFilter, setGroupFilter] = useState<string[]>([]);
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: <kbd>r</kbd>
```
#### 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

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

80
app/groups/page.tsx Normal file
View File

@@ -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 (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Process Groups</h1>
<Card className="border-destructive/50">
<CardContent className="p-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Failed to load processes</h2>
<p className="text-muted-foreground mb-4">
Could not connect to Supervisor. Please check your configuration.
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Process Groups</h1>
<p className="text-muted-foreground mt-1">
Manage processes organized by groups with batch operations
</p>
</div>
<Button
onClick={() => refetch()}
variant="outline"
size="sm"
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={isLoading ? 'animate-spin h-4 w-4' : 'h-4 w-4'} />
Refresh
</Button>
</div>
{isLoading ? (
<div className="space-y-6">
{[1, 2].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-8">
<div className="h-8 bg-muted rounded w-1/3 mb-4"></div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((j) => (
<div key={j} className="h-32 bg-muted rounded"></div>
))}
</div>
</CardContent>
</Card>
))}
</div>
) : processes && processes.length > 0 ? (
<GroupView processes={processes} />
) : (
<Card>
<CardContent className="p-12 text-center">
<p className="text-muted-foreground">No processes found</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,11 +1,15 @@
'use client'; 'use client';
import { useState } from 'react';
import { useProcesses } from '@/lib/hooks/useSupervisor'; import { useProcesses } from '@/lib/hooks/useSupervisor';
import { ProcessCard } from '@/components/process/ProcessCard'; 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 { RefreshCw, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export default function ProcessesPage() { export default function ProcessesPage() {
const [viewMode, setViewMode] = useState<'flat' | 'grouped'>('flat');
const { data: processes, isLoading, isError, refetch } = useProcesses(); const { data: processes, isLoading, isError, refetch } = useProcesses();
if (isLoading) { if (isLoading) {
@@ -49,16 +53,21 @@ export default function ProcessesPage() {
{processes?.length ?? 0} processes configured {processes?.length ?? 0} processes configured
</p> </p>
</div> </div>
<Button variant="outline" onClick={() => refetch()}> <div className="flex items-center gap-4">
<RefreshCw className="h-4 w-4 mr-2" /> <GroupSelector viewMode={viewMode} onViewModeChange={setViewMode} />
Refresh <Button variant="outline" onClick={() => refetch()}>
</Button> <RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div> </div>
{processes && processes.length === 0 ? ( {processes && processes.length === 0 ? (
<div className="flex flex-col items-center justify-center p-12 text-center border-2 border-dashed rounded-lg"> <div className="flex flex-col items-center justify-center p-12 text-center border-2 border-dashed rounded-lg">
<p className="text-muted-foreground">No processes configured</p> <p className="text-muted-foreground">No processes configured</p>
</div> </div>
) : viewMode === 'grouped' ? (
<GroupView processes={processes || []} />
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{processes?.map((process) => ( {processes?.map((process) => (

View File

@@ -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 (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="h-8 w-8 p-0"
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
<div>
<CardTitle className="text-xl">{groupName}</CardTitle>
<div className="flex gap-3 mt-1 text-sm">
<span className="text-muted-foreground">
Total: <span className="font-medium text-foreground">{stats.total}</span>
</span>
<span className="text-success">
Running: <span className="font-medium">{stats.running}</span>
</span>
<span className="text-muted-foreground">
Stopped: <span className="font-medium">{stats.stopped}</span>
</span>
{stats.fatal > 0 && (
<span className="text-destructive">
Fatal: <span className="font-medium">{stats.fatal}</span>
</span>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant="success"
size="sm"
onClick={handleStart}
disabled={isLoading || stats.stopped === 0}
className="gap-2"
>
<Play className="h-4 w-4" />
Start All
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleStop}
disabled={isLoading || stats.running === 0}
className="gap-2"
>
<Square className="h-4 w-4" />
Stop All
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={isLoading}
className="gap-2"
>
<RotateCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
Restart All
</Button>
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="pt-0">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{processes.map((process) => (
<ProcessCard
key={`${process.group}:${process.name}`}
process={process}
/>
))}
</div>
</CardContent>
)}
</Card>
);
}

View File

@@ -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 (
<div className="flex gap-2 items-center">
<span className="text-sm font-medium text-muted-foreground">View:</span>
<div className="flex gap-1 border rounded-md p-1">
<Button
variant={viewMode === 'flat' ? 'default' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('flat')}
className="gap-2"
>
<LayoutGrid className="h-4 w-4" />
Flat
</Button>
<Button
variant={viewMode === 'grouped' ? 'default' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('grouped')}
className="gap-2"
>
<List className="h-4 w-4" />
Grouped
</Button>
</div>
</div>
);
}

View File

@@ -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<string, ProcessInfo[]>);
// Sort groups alphabetically
const sortedGroups = Object.keys(groupedProcesses).sort();
return (
<div className="space-y-6">
{sortedGroups.map((groupName) => (
<GroupCard
key={groupName}
groupName={groupName}
processes={groupedProcesses[groupName]}
/>
))}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils/cn';
const navItems = [ const navItems = [
{ href: '/', label: 'Dashboard' }, { href: '/', label: 'Dashboard' },
{ href: '/processes', label: 'Processes' }, { href: '/processes', label: 'Processes' },
{ href: '/groups', label: 'Groups' },
{ href: '/logs', label: 'Logs' }, { href: '/logs', label: 'Logs' },
{ href: '/config', label: 'Configuration' }, { href: '/config', label: 'Configuration' },
]; ];

View File

@@ -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}`);
},
});
}