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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useProcesses } from '@/lib/hooks/useSupervisor';
|
||||
import { ProcessCard } from '@/components/process/ProcessCard';
|
||||
import { GroupView } from '@/components/groups/GroupView';
|
||||
import { GroupSelector } from '@/components/groups/GroupSelector';
|
||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ProcessesPage() {
|
||||
const [viewMode, setViewMode] = useState<'flat' | 'grouped'>('flat');
|
||||
const { data: processes, isLoading, isError, refetch } = useProcesses();
|
||||
|
||||
if (isLoading) {
|
||||
@@ -49,16 +53,21 @@ export default function ProcessesPage() {
|
||||
{processes?.length ?? 0} processes configured
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<GroupSelector viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{processes && processes.length === 0 ? (
|
||||
<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>
|
||||
</div>
|
||||
) : viewMode === 'grouped' ? (
|
||||
<GroupView processes={processes || []} />
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{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 = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/processes', label: 'Processes' },
|
||||
{ href: '/groups', label: 'Groups' },
|
||||
{ href: '/logs', label: 'Logs' },
|
||||
{ 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