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
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:
886
NEXT_STEPS.md
Normal file
886
NEXT_STEPS.md
Normal 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
|
||||||
35
app/api/supervisor/groups/[name]/restart/route.ts
Normal file
35
app/api/supervisor/groups/[name]/restart/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/supervisor/groups/[name]/start/route.ts
Normal file
30
app/api/supervisor/groups/[name]/start/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/supervisor/groups/[name]/stop/route.ts
Normal file
30
app/api/supervisor/groups/[name]/stop/route.ts
Normal 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
80
app/groups/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => (
|
||||||
|
|||||||
132
components/groups/GroupCard.tsx
Normal file
132
components/groups/GroupCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/groups/GroupSelector.tsx
Normal file
37
components/groups/GroupSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
components/groups/GroupView.tsx
Normal file
35
components/groups/GroupView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user