Compare commits

...

17 Commits

Author SHA1 Message Date
813f6d4c75 fix: make ReloadConfigResult fields optional with default empty arrays
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m33s
The Supervisor API doesn't always return 'changed' and 'removed' arrays
when reloading configuration, causing Zod validation errors.

Made all three fields (added, changed, removed) optional with default
empty arrays to handle cases where the API omits them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 05:32:58 +01:00
7f1c110f8f style: center header content with mx-auto
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m13s
Added mx-auto to navbar container to center the header content.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 00:10:11 +01:00
f83ecf864a fix: make autorestart field optional in ConfigInfo schema
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m10s
The autorestart field is not always present in the Supervisor API
response for getAllConfigInfo(), causing Zod validation errors.

Changed autorestart from required to optional field using .optional().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 23:04:04 +01:00
2d5ffac56c fix: correct readLog parameters for Supervisor XML-RPC API
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m13s
Fixed the readLog() call to use the correct parameters for reading
from the end of the log file. When using a negative offset to read
the last N bytes, the length parameter must be 0, not a positive number.

Changes:
- Updated fetchMainLog default length from 4096 to 0
- Updated API route default length from '4096' to '0'

Correct usage:
- readLog(-4096, 0) - Read last 4096 bytes from end of file
- readLog(0, 4096) - Read 4096 bytes from start of file

This fixes the INCORRECT_PARAMETERS error when fetching the main
supervisord log.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:59:22 +01:00
bdec163fb0 refactor: simplify readLog to use standard Supervisor XML-RPC API
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m11s
Removed SUPERVISOR_LOGFILE environment variable and simplified readLog()
to use the standard 2-parameter XML-RPC API that relies on supervisord's
configured logfile path.

Changes:
- Removed SUPERVISOR_LOGFILE from .env.example
- Simplified SupervisorClient.readLog() to accept only offset and length
- Removed logfile path parameter and all environment variable logic
- Fixed mobile nav container padding (removed px-4 py-6)

The readLog() method now uses the standard supervisor.readLog(offset, length)
XML-RPC call, which automatically reads from the logfile path configured
in supervisord.conf.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:58:03 +01:00
c50274452c feat: make Supervisor logfile path configurable via environment variable
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m12s
Added SUPERVISOR_LOGFILE environment variable to configure the path to
the Supervisor main logfile for reading via XML-RPC.

Changes:
- Added SUPERVISOR_LOGFILE to .env.example with default path
- Removed getLogfilePath() method and cachedLogfilePath field from SupervisorClient
- Updated readLog() to use environment variable with fallback chain:
  1. Explicitly provided logfilePath parameter
  2. SUPERVISOR_LOGFILE environment variable
  3. Default: /var/log/supervisor/supervisord.log
- Added debug logging to readLog() for troubleshooting

This allows users to configure the correct logfile path for their
Supervisor installation when using the custom 3-parameter XML-RPC
readLog implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:46:53 +01:00
145d37193c fix: update ConfigInfo schema to match Supervisor API response
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m11s
Updated the ConfigInfoSchema to accurately reflect the data structure
returned by Supervisor's getAllConfigInfo() XML-RPC method, fixing Zod
validation errors on the /config page.

Schema changes:
- Removed fields not in API: environment, priority, process_name, numprocs, numprocs_start, username
- Added missing fields: autorestart, killasgroup, process_prio, group_prio, stdout_syslog, stderr_syslog, serverurl
- Fixed type mismatches:
  - stopsignal: string → number (API returns signal numbers like 15)
  - uid: number|null → string (API returns username strings)
  - directory: string|null → string

Updated ConfigTable.tsx to use process_prio instead of priority and
removed the non-existent numprocs column.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:39:33 +01:00
20877abbc7 fix: update supervisor.readLog to accept 3 parameters with logfile path
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m10s
Update the readLog method to match custom Supervisor API requirements:
- Add third parameter (logfile_path) to supervisor.readLog call
- Retrieve logfile path from supervisor.getAPIVersion() metadata
- Cache logfile path after first retrieval to avoid repeated API calls
- Support optional explicit logfile_path parameter
- Fallback to default path if metadata extraction fails

Implementation details:
- Added cachedLogfilePath private field to SupervisorClient
- Added private getLogfilePath() method to extract path from API version
- Updated readLog signature: (offset, length, logfilePath?)
- Automatic path retrieval when logfilePath not provided
- Supports multiple metadata formats (logfile, logfile_path properties)
- Logs warnings if path extraction fails, uses sensible default

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:27:53 +01:00
9fcb0447ee fix: apply background and blur to nav element for mobile menu
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m12s
Move background styling to the nav element itself with rounded corners.
- Add bg-background/95 backdrop-blur-md to nav element
- Add rounded-lg and p-4 for better visual appearance
- Creates frosted glass card effect for the mobile menu

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:18:24 +01:00
df3e022049 fix: correct mobile menu background placement with blur effect
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m10s
Move background styling from outer overlay to inner container for proper visibility.
- Remove bg-background from outer fixed div
- Add bg-background/95 backdrop-blur-md h-full to inner container
- Creates frosted glass effect with proper blur

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:04:24 +01:00
dda335d501 fix: resolve 7 critical UI issues - charts, layouts, and mobile responsiveness
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m8s
This commit fixes all reported UI issues across the dashboard:

## Issue 1: Chart Colors and Tooltips 
- Create chartColors utility with static hex colors for Recharts compatibility
- Replace CSS variable colors (hsl(var(--))) with hex colors in all charts
- Add custom tooltip styling with dark background and white text for readability
- Fixes: ProcessStateChart, ProcessUptimeChart, GroupStatistics

## Issue 2: Process Card Heights 
- Add h-full and flex flex-col to ProcessCard component
- Add auto-rows-fr to process grid layout
- Ensures all cards have consistent heights regardless of content

## Issue 3: Batch Actions Button Labels 
- Simplify button labels from "Start Selected" to "Start"
- Remove "Stop Selected" to "Stop", "Restart Selected" to "Restart"
- Labels now always visible on all screen sizes

## Issue 4: Mobile Menu Background 
- Change mobile menu from semi-transparent (bg-background/95) to solid (bg-background)
- Removes backdrop blur for better visibility

## Issue 5: Group Header Button Overflow 
- Add flex-wrap to button container in GroupCard
- Stack buttons vertically on mobile (flex-col md:flex-row)
- Buttons take full width on mobile, auto width on desktop

## Issue 6: Logs Search Input Overflow 
- Change LogSearch from max-w-md to w-full sm:flex-1 sm:max-w-md
- Search input now full width on mobile, constrained on desktop

## Issue 7: Logs Action Button Overflow 
- Add flex-wrap to LogControls button container
- Buttons wrap to new row when space is limited

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:52:35 +01:00
791c99097c fix: resolve build errors in api-logger imports and React Query config
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m10s
- Fix import of generateRequestId in events/route.ts (import from logger instead of api-logger)
- Remove deprecated logger config from QueryClient (no longer supported in latest React Query)

These changes resolve TypeScript compilation errors and allow the build to succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:25:15 +01:00
06dd1c20d0 feat: comprehensive responsive design implementation for mobile devices
Some checks failed
Build and Push Docker Image to Gitea / build-and-push (push) Failing after 58s
This commit implements a complete responsive design overhaul making the Supervisor UI fully mobile-friendly and beautiful across all devices (320px phones to 4K displays).

## Phase 1: Mobile Navigation
- Add hamburger menu to Navbar with slide-out drawer
- Auto-close on navigation with body scroll lock
- Responsive logo sizing

## Phase 2: Touch-Friendly Buttons
- Increase touch targets to 44px on mobile (36px for small buttons)
- Add responsive button layouts in ProcessCard
- Flex-wrap prevents cramped button rows

## Phase 3: Responsive Spacing & Typography
- Add responsive padding to Card components (p-4 md:p-6)
- Scale typography across breakpoints (text-xl md:text-2xl)
- Responsive spacing in AppLayout and all pages

## Phase 4: Mobile-Friendly Tables
- Dual layout for ConfigTable: table on desktop, cards on mobile
- Preserve all data with proper formatting and wrapping
- Hide table on mobile, show card-based layout

## Phase 5: Modal Improvements
- Add horizontal padding (p-4) to all modals
- Prevent edge-touching on mobile devices
- Fixed SignalSender, KeyboardShortcutsHelp, StdinInput modals

## Phase 6: Page-Specific Layouts
- Processes page: responsive header, controls, and grid spacing
- BatchActions bar: full-width on mobile, centered on desktop
- Logs page: responsive controls and height calculations
- Config page: responsive header and error states

## Phase 7: Polish & Final Touches
- Add viewport meta tag to layout
- Responsive empty states and loading skeletons
- Consistent responsive sizing across all error messages
- Mobile-first typography scaling

🎉 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:21:22 +01:00
54eb14bf20 fix: correct AppLayout flex direction to prevent content hanging on right side
Changed flex container from horizontal (default) to vertical (flex-col) to properly
stack navbar and main content. This fixes the layout issue where content was
hanging on the right side of the window.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:03:00 +01:00
6985032006 feat(logging): add comprehensive error boundary and global error handling (Phase 6)
Implemented complete client-side error handling and reporting system:

Error Boundary Component (components/providers/ErrorBoundary.tsx):
- React Error Boundary to catch component errors
- Automatic error logging to client logger
- Error reporting to server endpoint
- User-friendly fallback UI with error details (dev mode)
- Reset and reload functionality
- Custom fallback support via props

Global Error Handler (lib/utils/global-error-handler.ts):
- Window error event listener for uncaught errors
- Unhandled promise rejection handler
- Automatic error reporting to server
- Comprehensive error metadata collection:
  - Error message and stack trace
  - URL, user agent, timestamp
  - Filename, line number, column number (for window errors)

Client Error Reporting Endpoint (app/api/client-error/route.ts):
- Server-side endpoint to receive client errors
- Integrated with withLogging() for automatic server logging
- Accepts error reports from both ErrorBoundary and global handlers
- Returns acknowledgement to client

Error Boundary Integration (components/providers/Providers.tsx):
- Wrapped entire app in ErrorBoundary
- Initialized global error handlers on mount
- Catches React errors, window errors, and unhandled rejections

Error Reporting Features:
- Duplicate error tracking prevention
- Async error reporting (non-blocking)
- Graceful degradation (fails silently if reporting fails)
- Development vs production error display
- Structured error metadata for debugging

All errors now:
- Log to browser console via client logger
- Report to server for centralized logging
- Display user-friendly error UI
- Include full context for debugging
- Work across React, window, and promise contexts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:00:37 +01:00
95acf4542b feat(logging): add comprehensive client-side logging (Phase 5)
Created client-side logging infrastructure for React Query and hooks:

New Client Logger (lib/utils/client-logger.ts):
- Environment-aware logging (debug/info in dev, warn/error in prod)
- Structured logging with context objects
- Performance timing helpers (time/timeEnd)
- Group logging for related operations
- Factory functions for hook/query/mutation-specific loggers

React Query Configuration (components/providers/Providers.tsx):
- Added custom logger to QueryClient
- Integrated with client-side logger for consistent formatting
- Configured mutation retry defaults

SSE Hook Logging (lib/hooks/useEventSource.ts):
- Connection lifecycle logging (connect/disconnect/reconnect)
- Heartbeat and process update event logging
- Error tracking with reconnection attempt details
- Exponential backoff logging for reconnections

Supervisor Hooks Logging (lib/hooks/useSupervisor.ts):
- Added logging to all critical mutation hooks:
  - Process control (start/stop/restart)
  - Batch operations (start-all/stop-all/restart-all)
  - Configuration reload
  - Signal operations
- Logs mutation start, success, and error states
- Includes contextual metadata (process names, signals, etc.)

All client-side logs:
- Use structured format with timestamps
- Include relevant context for debugging
- Respect environment (verbose in dev, minimal in prod)
- Compatible with browser devtools

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 20:57:24 +01:00
d592b58b75 feat(logging): add comprehensive logging to all API routes (Phase 4)
Applied withLogging() wrapper to all 24 API routes for consistent logging:

Process Control Routes:
- Start/stop/restart individual processes
- Start-all/stop-all/restart-all batch operations

Signal Routes:
- Signal individual processes
- Signal all processes
- Signal process groups

Group Management Routes:
- Start/stop/restart process groups
- Signal operations for groups

Configuration Routes:
- Get all configs (GET)
- Reload configuration (POST)
- Add/remove process groups (POST/DELETE)

Log Routes:
- Read main supervisord log
- Read process stdout/stderr logs
- Clear process logs (individual and all)

System Routes:
- Get system info
- Get all processes info
- Get individual process info
- Send stdin to process

All routes now include:
- Request/response logging with timing
- Automatic error handling and correlation IDs
- X-Request-ID header propagation
- Consistent metadata in responses

Also fixed Next.js 16 deprecation:
- Moved experimental.serverComponentsExternalPackages to serverExternalPackages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 20:53:23 +01:00
56 changed files with 1370 additions and 731 deletions

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { withLogging } from '@/lib/utils/api-logger';
interface ClientErrorReport {
message: string;
stack?: string;
componentStack?: string;
name?: string;
url: string;
userAgent: string;
timestamp: string;
type?: 'error' | 'unhandledrejection';
filename?: string;
lineno?: number;
colno?: number;
}
export const POST = withLogging(async (request: NextRequest) => {
const errorReport: ClientErrorReport = await request.json();
// The withLogging wrapper will automatically log this with the error details
// We can return success to acknowledge receipt
return NextResponse.json(
{
success: true,
message: 'Client error logged successfully',
timestamp: new Date().toISOString(),
},
{ status: 200 }
);
}, 'logClientError');

View File

@@ -1,60 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
// POST - Add a process group
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name } = body;
export const POST = withLogging(async (request: NextRequest) => {
const body = await request.json();
const { name } = body;
if (!name) {
return NextResponse.json(
{ error: 'Group name is required' },
{ status: 400 }
);
}
const client = createSupervisorClient();
const result = await client.addProcessGroup(name);
return NextResponse.json({
success: result,
message: `Process group '${name}' added successfully`,
});
} catch (error: any) {
console.error('Supervisor add process group error:', error);
if (!name) {
return NextResponse.json(
{ error: error.message || 'Failed to add process group' },
{ status: 500 }
{ error: 'Group name is required' },
{ status: 400 }
);
}
}
// DELETE - Remove a process group
export async function DELETE(request: NextRequest) {
try {
const body = await request.json();
const { name } = body;
const client = createSupervisorClient();
const result = await client.addProcessGroup(name);
if (!name) {
return NextResponse.json(
{ error: 'Group name is required' },
{ status: 400 }
);
}
return NextResponse.json({
success: result,
message: `Process group '${name}' added successfully`,
groupName: name,
});
}, 'addProcessGroup');
const client = createSupervisorClient();
const result = await client.removeProcessGroup(name);
export const DELETE = withLogging(async (request: NextRequest) => {
const body = await request.json();
const { name } = body;
return NextResponse.json({
success: result,
message: `Process group '${name}' removed successfully`,
});
} catch (error: any) {
console.error('Supervisor remove process group error:', error);
if (!name) {
return NextResponse.json(
{ error: error.message || 'Failed to remove process group' },
{ status: 500 }
{ error: 'Group name is required' },
{ status: 400 }
);
}
}
const client = createSupervisorClient();
const result = await client.removeProcessGroup(name);
return NextResponse.json({
success: result,
message: `Process group '${name}' removed successfully`,
groupName: name,
});
}, 'removeProcessGroup');

View File

@@ -1,21 +1,14 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
// POST - Reload configuration
export async function POST() {
try {
const client = createSupervisorClient();
const result = await client.reloadConfig();
return NextResponse.json({
success: true,
message: 'Configuration reloaded',
result,
});
} catch (error: any) {
console.error('Supervisor reload config error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to reload configuration' },
{ status: 500 }
);
}
}
export const POST = withLogging(async (request: NextRequest) => {
const client = createSupervisorClient();
const result = await client.reloadConfig();
return NextResponse.json({
success: true,
message: 'Configuration reloaded',
result,
});
}, 'reloadConfig');

View File

@@ -1,17 +1,9 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
// GET - Get all process configurations
export async function GET() {
try {
const client = createSupervisorClient();
const configs = await client.getAllConfigInfo();
return NextResponse.json(configs);
} catch (error: any) {
console.error('Supervisor get config error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch configuration' },
{ status: 500 }
);
}
}
export const GET = withLogging(async (request: NextRequest) => {
const client = createSupervisorClient();
const configs = await client.getAllConfigInfo();
return NextResponse.json(configs);
}, 'getAllConfigInfo');

View File

@@ -1,5 +1,7 @@
import { NextRequest } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { createApiLogger } from '@/lib/utils/api-logger';
import { formatError, generateRequestId } from '@/lib/utils/logger';
export const dynamic = 'force-dynamic';
@@ -8,9 +10,16 @@ export const dynamic = 'force-dynamic';
* Polls supervisor every 2 seconds and sends state changes to clients
*/
export async function GET(request: NextRequest) {
const requestId = generateRequestId();
const logger = createApiLogger(request, 'SSE-Events');
const encoder = new TextEncoder();
let intervalId: NodeJS.Timeout | null = null;
let previousState: string | null = null;
let pollCount = 0;
let stateChangeCount = 0;
logger.info({ requestId }, 'SSE connection initiated');
const stream = new ReadableStream({
async start(controller) {
@@ -22,9 +31,13 @@ export async function GET(request: NextRequest) {
// Send initial connection message
sendEvent('connected', { timestamp: Date.now() });
logger.debug({ requestId }, 'SSE connected event sent');
// Poll supervisor for state changes
const pollSupervisor = async () => {
pollCount++;
const pollStartTime = Date.now();
try {
const client = createSupervisorClient();
const processes = await client.getAllProcessInfo();
@@ -41,17 +54,40 @@ export async function GET(request: NextRequest) {
// Send update if state changed
if (currentState !== previousState) {
stateChangeCount++;
sendEvent('process-update', {
processes,
timestamp: Date.now(),
});
logger.info({
requestId,
pollCount,
stateChangeCount,
processCount: processes.length,
duration: Date.now() - pollStartTime,
}, `Process state change detected (change #${stateChangeCount})`);
previousState = currentState;
}
// Send heartbeat every poll
// Send heartbeat every poll (reduced logging - only log every 10th heartbeat)
sendEvent('heartbeat', { timestamp: Date.now() });
if (pollCount % 10 === 0) {
logger.debug({
requestId,
pollCount,
stateChangeCount,
duration: Date.now() - pollStartTime,
}, `SSE heartbeat #${pollCount}`);
}
} catch (error: any) {
console.error('SSE polling error:', error);
const errorInfo = formatError(error);
logger.error({
requestId,
pollCount,
error: errorInfo,
duration: Date.now() - pollStartTime,
}, `SSE polling error: ${errorInfo.message}`);
sendEvent('error', {
message: error.message || 'Failed to fetch process state',
timestamp: Date.now(),
@@ -70,6 +106,12 @@ export async function GET(request: NextRequest) {
if (intervalId) {
clearInterval(intervalId);
}
logger.info({
requestId,
pollCount,
stateChangeCount,
duration: Date.now() - Date.now(),
}, 'SSE connection closed');
},
});
@@ -79,6 +121,7 @@ export async function GET(request: NextRequest) {
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
'X-Request-ID': requestId,
},
});
}

View File

@@ -1,35 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
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;
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const body = await request.json().catch(() => ({}));
const wait = body.wait ?? true;
const client = createSupervisorClient();
const client = createSupervisorClient();
// Stop all processes in the group first
await client.stopProcessGroup(name, wait);
// Stop all processes in the group first
await client.stopProcessGroup(name, wait);
// Then start them
const results = await client.startProcessGroup(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 }
);
}
}
return NextResponse.json({
success: true,
message: `Restarted process group: ${name}`,
results,
groupName: name,
wait,
});
}, 'restartProcessGroup');

View File

@@ -1,37 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
interface RouteParams {
params: Promise<{ name: string }>;
}
// POST - Send signal to all processes in a group
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const body = await request.json();
const { signal } = body;
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const body = await request.json();
const { signal } = body;
if (!signal) {
return NextResponse.json(
{ error: 'Signal is required' },
{ status: 400 }
);
}
const client = createSupervisorClient();
const results = await client.signalProcessGroup(name, signal);
return NextResponse.json({
success: true,
message: `Signal ${signal} sent to group ${name}`,
results,
});
} catch (error: any) {
console.error('Supervisor signal process group error:', error);
if (!signal) {
return NextResponse.json(
{ error: error.message || 'Failed to send signal to process group' },
{ status: 500 }
{ error: 'Signal is required' },
{ status: 400 }
);
}
}
const client = createSupervisorClient();
const results = await client.signalProcessGroup(name, signal);
return NextResponse.json({
success: true,
message: `Signal ${signal} sent to group ${name}`,
results,
groupName: name,
signal,
});
}, 'signalProcessGroup');

View File

@@ -1,30 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
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;
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
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);
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 }
);
}
}
return NextResponse.json({
success: true,
message: `Started process group: ${name}`,
results,
groupName: name,
wait,
});
}, 'startProcessGroup');

View File

@@ -1,30 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
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;
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
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);
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 }
);
}
}
return NextResponse.json({
success: true,
message: `Stopped process group: ${name}`,
results,
groupName: name,
wait,
});
}, 'stopProcessGroup');

View File

@@ -1,39 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
export const dynamic = 'force-dynamic';
// GET main supervisord log
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const offset = parseInt(searchParams.get('offset') || '-4096', 10);
const length = parseInt(searchParams.get('length') || '4096', 10);
export const GET = withLogging(async (request: NextRequest) => {
const searchParams = request.nextUrl.searchParams;
const offset = parseInt(searchParams.get('offset') || '-4096', 10);
const length = parseInt(searchParams.get('length') || '0', 10);
const client = createSupervisorClient();
const logs = await client.readLog(offset, length);
const client = createSupervisorClient();
const logs = await client.readLog(offset, length);
return NextResponse.json({ logs, offset, length });
} catch (error: any) {
console.error('Supervisor main log error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch main log' },
{ status: 500 }
);
}
}
return NextResponse.json({ logs, offset, length });
}, 'readMainLog');
// DELETE - Clear main supervisord log
export async function DELETE() {
try {
const client = createSupervisorClient();
const result = await client.clearLog();
return NextResponse.json({ success: result, message: 'Main log cleared' });
} catch (error: any) {
console.error('Supervisor clear main log error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to clear main log' },
{ status: 500 }
);
}
}
export const DELETE = withLogging(async (request: NextRequest) => {
const client = createSupervisorClient();
const result = await client.clearLog();
return NextResponse.json({ success: result, message: 'Main log cleared' });
}, 'clearMainLog');

View File

@@ -1,22 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
interface RouteParams {
params: Promise<{ name: string }>;
}
// DELETE - Clear process logs (both stdout and stderr)
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const client = createSupervisorClient();
const result = await client.clearProcessLogs(name);
return NextResponse.json({ success: result, message: `Logs cleared for ${name}` });
} catch (error: any) {
console.error('Supervisor clear process logs error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to clear process logs' },
{ status: 500 }
);
}
}
export const DELETE = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const client = createSupervisorClient();
const result = await client.clearProcessLogs(name);
return NextResponse.json({
success: result,
message: `Logs cleared for ${name}`,
processName: name,
});
}, 'clearProcessLogs');

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
export const dynamic = 'force-dynamic';
@@ -7,21 +8,14 @@ interface RouteParams {
params: Promise<{ name: string }>;
}
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const searchParams = request.nextUrl.searchParams;
const offset = parseInt(searchParams.get('offset') || '-4096', 10);
const length = parseInt(searchParams.get('length') || '4096', 10);
export const GET = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const searchParams = request.nextUrl.searchParams;
const offset = parseInt(searchParams.get('offset') || '-4096', 10);
const length = parseInt(searchParams.get('length') || '4096', 10);
const client = createSupervisorClient();
const logs = await client.tailProcessStderrLog(name, offset, length);
return NextResponse.json(logs);
} catch (error: any) {
console.error('Supervisor stderr log error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch stderr logs' },
{ status: 500 }
);
}
}
const client = createSupervisorClient();
const logs = await client.tailProcessStderrLog(name, offset, length);
return NextResponse.json(logs);
}, 'readProcessStderr');

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
export const dynamic = 'force-dynamic';
@@ -7,21 +8,14 @@ interface RouteParams {
params: Promise<{ name: string }>;
}
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const searchParams = request.nextUrl.searchParams;
const offset = parseInt(searchParams.get('offset') || '-4096', 10);
const length = parseInt(searchParams.get('length') || '4096', 10);
export const GET = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const searchParams = request.nextUrl.searchParams;
const offset = parseInt(searchParams.get('offset') || '-4096', 10);
const length = parseInt(searchParams.get('length') || '4096', 10);
const client = createSupervisorClient();
const logs = await client.tailProcessStdoutLog(name, offset, length);
return NextResponse.json(logs);
} catch (error: any) {
console.error('Supervisor stdout log error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch stdout logs' },
{ status: 500 }
);
}
}
const client = createSupervisorClient();
const logs = await client.tailProcessStdoutLog(name, offset, length);
return NextResponse.json(logs);
}, 'readProcessStdout');

View File

@@ -1,21 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
interface RouteParams {
params: Promise<{ name: string }>;
}
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const client = createSupervisorClient();
const result = await client.restartProcess(name);
return NextResponse.json({ success: result, message: `Process ${name} restarted` });
} catch (error: any) {
console.error('Supervisor restart process error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to restart process' },
{ status: 500 }
);
}
}
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const client = createSupervisorClient();
const result = await client.restartProcess(name);
return NextResponse.json({
success: result,
message: `Process ${name} restarted`,
processName: name,
});
}, 'restartProcess');

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
export const dynamic = 'force-dynamic';
@@ -7,17 +8,11 @@ interface RouteParams {
params: Promise<{ name: string }>;
}
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const client = createSupervisorClient();
const processInfo = await client.getProcessInfo(name);
return NextResponse.json(processInfo);
} catch (error: any) {
console.error('Supervisor process info error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch process info' },
{ status: 500 }
);
}
}
export const GET = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const client = createSupervisorClient();
const processInfo = await client.getProcessInfo(name);
return NextResponse.json(processInfo);
}, 'getProcessInfo');

View File

@@ -1,36 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
interface RouteParams {
params: Promise<{ name: string }>;
}
// POST - Send signal to a process
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const body = await request.json();
const { signal } = body;
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const body = await request.json();
const { signal } = body;
if (!signal) {
return NextResponse.json(
{ error: 'Signal is required' },
{ status: 400 }
);
}
const client = createSupervisorClient();
const result = await client.signalProcess(name, signal);
return NextResponse.json({
success: result,
message: `Signal ${signal} sent to ${name}`,
});
} catch (error: any) {
console.error('Supervisor signal process error:', error);
if (!signal) {
return NextResponse.json(
{ error: error.message || 'Failed to send signal to process' },
{ status: 500 }
{ error: 'Signal is required' },
{ status: 400 }
);
}
}
const client = createSupervisorClient();
const result = await client.signalProcess(name, signal);
return NextResponse.json({
success: result,
message: `Signal ${signal} sent to ${name}`,
processName: name,
signal,
});
}, 'signalProcess');

View File

@@ -1,24 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
interface RouteParams {
params: Promise<{ name: string }>;
}
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const body = await request.json().catch(() => ({}));
const wait = body.wait !== undefined ? body.wait : true;
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const body = await request.json().catch(() => ({}));
const wait = body.wait !== undefined ? body.wait : true;
const client = createSupervisorClient();
const result = await client.startProcess(name, wait);
return NextResponse.json({ success: result, message: `Process ${name} started` });
} catch (error: any) {
console.error('Supervisor start process error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to start process' },
{ status: 500 }
);
}
}
const client = createSupervisorClient();
const result = await client.startProcess(name, wait);
return NextResponse.json({
success: result,
message: `Process ${name} started`,
processName: name,
wait,
});
}, 'startProcess');

View File

@@ -1,36 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
interface RouteParams {
params: Promise<{ name: string }>;
}
// POST - Send input to process stdin
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const body = await request.json();
const { chars } = body;
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const body = await request.json();
const { chars } = body;
if (chars === undefined || chars === null) {
return NextResponse.json(
{ error: 'Input characters are required' },
{ status: 400 }
);
}
const client = createSupervisorClient();
const result = await client.sendProcessStdin(name, chars);
return NextResponse.json({
success: result,
message: `Input sent to ${name}`,
});
} catch (error: any) {
console.error('Supervisor send stdin error:', error);
if (chars === undefined || chars === null) {
return NextResponse.json(
{ error: error.message || 'Failed to send input to process' },
{ status: 500 }
{ error: 'Input characters are required' },
{ status: 400 }
);
}
}
const client = createSupervisorClient();
const result = await client.sendProcessStdin(name, chars);
return NextResponse.json({
success: result,
message: `Input sent to ${name}`,
processName: name,
});
}, 'sendProcessStdin');

View File

@@ -1,24 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
interface RouteParams {
params: Promise<{ name: string }>;
}
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { name } = await params;
const body = await request.json().catch(() => ({}));
const wait = body.wait !== undefined ? body.wait : true;
export const POST = withLogging(async (request: NextRequest, { params }: RouteParams) => {
const { name } = await params;
const body = await request.json().catch(() => ({}));
const wait = body.wait !== undefined ? body.wait : true;
const client = createSupervisorClient();
const result = await client.stopProcess(name, wait);
return NextResponse.json({ success: result, message: `Process ${name} stopped` });
} catch (error: any) {
console.error('Supervisor stop process error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to stop process' },
{ status: 500 }
);
}
}
const client = createSupervisorClient();
const result = await client.stopProcess(name, wait);
return NextResponse.json({
success: result,
message: `Process ${name} stopped`,
processName: name,
wait,
});
}, 'stopProcess');

View File

@@ -1,21 +1,14 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
// POST - Clear all process logs
export async function POST() {
try {
const client = createSupervisorClient();
const results = await client.clearAllProcessLogs();
return NextResponse.json({
success: true,
message: 'All process logs cleared',
results
});
} catch (error: any) {
console.error('Supervisor clear all logs error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to clear all logs' },
{ status: 500 }
);
}
}
export const POST = withLogging(async (request: NextRequest) => {
const client = createSupervisorClient();
const results = await client.clearAllProcessLogs();
return NextResponse.json({
success: true,
message: 'All process logs cleared',
results,
});
}, 'clearAllProcessLogs');

View File

@@ -1,30 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
// POST - Restart all processes (stop then start)
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const wait = body.wait ?? true;
export const POST = withLogging(async (request: NextRequest) => {
const body = await request.json().catch(() => ({}));
const wait = body.wait ?? true;
const client = createSupervisorClient();
const client = createSupervisorClient();
// Stop all processes first
await client.stopAllProcesses(wait);
// Stop all processes first
await client.stopAllProcesses(wait);
// Then start them
const results = await client.startAllProcesses(wait);
// Then start them
const results = await client.startAllProcesses(wait);
return NextResponse.json({
success: true,
message: 'Restarted all processes',
results,
});
} catch (error: any) {
console.error('Supervisor restart all processes error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to restart all processes' },
{ status: 500 }
);
}
}
return NextResponse.json({
success: true,
message: 'Restarted all processes',
results,
wait,
});
}, 'restartAllProcesses');

View File

@@ -1,18 +1,12 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const client = createSupervisorClient();
const processes = await client.getAllProcessInfo();
return NextResponse.json(processes);
} catch (error: any) {
console.error('Supervisor processes error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch processes' },
{ status: 500 }
);
}
}
export const GET = withLogging(async (request: NextRequest) => {
const client = createSupervisorClient();
const processes = await client.getAllProcessInfo();
return NextResponse.json(processes);
}, 'getAllProcessInfo');

View File

@@ -1,32 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
// POST - Send signal to all processes
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { signal } = body;
export const POST = withLogging(async (request: NextRequest) => {
const body = await request.json();
const { signal } = body;
if (!signal) {
return NextResponse.json(
{ error: 'Signal is required' },
{ status: 400 }
);
}
const client = createSupervisorClient();
const results = await client.signalAllProcesses(signal);
return NextResponse.json({
success: true,
message: `Signal ${signal} sent to all processes`,
results,
});
} catch (error: any) {
console.error('Supervisor signal all processes error:', error);
if (!signal) {
return NextResponse.json(
{ error: error.message || 'Failed to send signal to all processes' },
{ status: 500 }
{ error: 'Signal is required' },
{ status: 400 }
);
}
}
const client = createSupervisorClient();
const results = await client.signalAllProcesses(signal);
return NextResponse.json({
success: true,
message: `Signal ${signal} sent to all processes`,
results,
signal,
});
}, 'signalAllProcesses');

View File

@@ -1,25 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
// POST - Start all processes
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const wait = body.wait ?? true;
export const POST = withLogging(async (request: NextRequest) => {
const body = await request.json().catch(() => ({}));
const wait = body.wait ?? true;
const client = createSupervisorClient();
const results = await client.startAllProcesses(wait);
const client = createSupervisorClient();
const results = await client.startAllProcesses(wait);
return NextResponse.json({
success: true,
message: 'Started all processes',
results,
});
} catch (error: any) {
console.error('Supervisor start all processes error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to start all processes' },
{ status: 500 }
);
}
}
return NextResponse.json({
success: true,
message: 'Started all processes',
results,
wait,
});
}, 'startAllProcesses');

View File

@@ -1,25 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
// POST - Stop all processes
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const wait = body.wait ?? true;
export const POST = withLogging(async (request: NextRequest) => {
const body = await request.json().catch(() => ({}));
const wait = body.wait ?? true;
const client = createSupervisorClient();
const results = await client.stopAllProcesses(wait);
const client = createSupervisorClient();
const results = await client.stopAllProcesses(wait);
return NextResponse.json({
success: true,
message: 'Stopped all processes',
results,
});
} catch (error: any) {
console.error('Supervisor stop all processes error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to stop all processes' },
{ status: 500 }
);
}
}
return NextResponse.json({
success: true,
message: 'Stopped all processes',
results,
wait,
});
}, 'stopAllProcesses');

View File

@@ -1,18 +1,12 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
import { withLogging } from '@/lib/utils/api-logger';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const client = createSupervisorClient();
const systemInfo = await client.getSystemInfo();
return NextResponse.json(systemInfo);
} catch (error: any) {
console.error('Supervisor system info error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch system info' },
{ status: 500 }
);
}
}
export const GET = withLogging(async (request: NextRequest) => {
const client = createSupervisorClient();
const systemInfo = await client.getSystemInfo();
return NextResponse.json(systemInfo);
}, 'getSystemInfo');

View File

@@ -12,13 +12,13 @@ export default function ConfigPage() {
if (isError) {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Configuration</h1>
<div className="space-y-4 md:space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold">Configuration</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 configuration</h2>
<p className="text-muted-foreground">
<CardContent className="p-8 md:p-12 text-center">
<AlertCircle className="h-10 w-10 md:h-12 md:w-12 text-destructive mx-auto mb-4" />
<h2 className="text-lg md:text-xl font-semibold mb-2">Failed to load configuration</h2>
<p className="text-sm md:text-base text-muted-foreground">
Could not connect to Supervisor. Please check your configuration.
</p>
</CardContent>
@@ -28,11 +28,11 @@ export default function ConfigPage() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-4 md:space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Configuration</h1>
<p className="text-muted-foreground mt-1">
<h1 className="text-2xl sm:text-3xl font-bold">Configuration</h1>
<p className="text-sm md:text-base text-muted-foreground mt-1">
Manage process configurations and supervisor settings
</p>
</div>
@@ -65,8 +65,8 @@ export default function ConfigPage() {
<ConfigTable configs={configs} />
) : (
<Card>
<CardContent className="p-12 text-center">
<p className="text-muted-foreground">No process configurations found</p>
<CardContent className="p-8 md:p-12 text-center">
<p className="text-sm md:text-base text-muted-foreground">No process configurations found</p>
</CardContent>
</Card>
)}

View File

@@ -12,6 +12,12 @@ const inter = Inter({
export const metadata: Metadata = {
title: 'Supervisor UI',
description: 'Modern web interface for Supervisor process management',
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
userScalable: true,
},
};
// Force dynamic rendering

View File

@@ -76,13 +76,13 @@ export default function LogsPage() {
if (processesError) {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Process Logs</h1>
<div className="space-y-4 md:space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold">Process Logs</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">
<CardContent className="p-8 md:p-12 text-center">
<AlertCircle className="h-10 w-10 md:h-12 md:w-12 text-destructive mx-auto mb-4" />
<h2 className="text-lg md:text-xl font-semibold mb-2">Failed to load processes</h2>
<p className="text-sm md:text-base text-muted-foreground">
Could not connect to Supervisor. Please check your configuration.
</p>
</CardContent>
@@ -92,20 +92,20 @@ export default function LogsPage() {
}
return (
<div className="space-y-6 h-[calc(100vh-12rem)] flex flex-col">
<div className="space-y-4 md:space-y-6 h-[calc(100vh-10rem)] md:h-[calc(100vh-12rem)] flex flex-col">
<div className="flex-shrink-0">
<h1 className="text-3xl font-bold">Process Logs</h1>
<p className="text-muted-foreground mt-1">Real-time log viewing and management</p>
<h1 className="text-2xl sm:text-3xl font-bold">Process Logs</h1>
<p className="text-sm md:text-base text-muted-foreground mt-1">Real-time log viewing and management</p>
</div>
{/* Controls */}
<Card className="flex-shrink-0">
<CardHeader className="pb-4">
<CardTitle className="text-lg">Log Controls</CardTitle>
<CardHeader className="pb-3 md:pb-4">
<CardTitle className="text-base md:text-lg">Log Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 md:space-y-4">
{/* Process Selector */}
<div className="flex flex-wrap gap-4">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium mb-2">Select Process</label>
<select
@@ -131,6 +131,7 @@ export default function LogsPage() {
variant={logType === 'stdout' ? 'default' : 'outline'}
size="sm"
onClick={() => setLogType('stdout')}
className="flex-1 sm:flex-initial"
>
Stdout
</Button>
@@ -138,6 +139,7 @@ export default function LogsPage() {
variant={logType === 'stderr' ? 'default' : 'outline'}
size="sm"
onClick={() => setLogType('stderr')}
className="flex-1 sm:flex-initial"
>
Stderr
</Button>
@@ -147,7 +149,7 @@ export default function LogsPage() {
</div>
{/* Search and Controls */}
<div className="flex flex-wrap gap-4 items-end">
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4 items-stretch sm:items-end">
<LogSearch value={searchTerm} onChange={setSearchTerm} />
<LogControls
isPlaying={isPlaying}

View File

@@ -39,12 +39,12 @@ export default function HomePage() {
return (
<div className="space-y-8 animate-fade-in">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex flex-col sm:flex-row items-start justify-between gap-4">
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
Supervisor Dashboard
</h1>
<p className="text-muted-foreground mt-2">
<p className="text-sm md:text-base text-muted-foreground mt-2">
Monitor and manage your processes in real-time
</p>
</div>

View File

@@ -183,9 +183,9 @@ export default function ProcessesPage() {
if (isLoading) {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Processes</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-4 md:space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold">Processes</h1>
<div className="grid gap-4 md:gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-64 bg-muted rounded-lg animate-pulse" />
))}
@@ -196,12 +196,12 @@ export default function ProcessesPage() {
if (isError) {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Processes</h1>
<div className="flex flex-col items-center justify-center p-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h2 className="text-xl font-semibold mb-2">Failed to load processes</h2>
<p className="text-muted-foreground mb-4">
<div className="space-y-4 md:space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold">Processes</h1>
<div className="flex flex-col items-center justify-center p-8 md:p-12 text-center">
<AlertCircle className="h-10 w-10 md:h-12 md:w-12 text-destructive mb-4" />
<h2 className="text-lg md:text-xl font-semibold mb-2">Failed to load processes</h2>
<p className="text-sm md:text-base text-muted-foreground mb-4">
Could not connect to Supervisor. Please check your configuration.
</p>
<Button onClick={() => refetch()}>
@@ -215,11 +215,12 @@ export default function ProcessesPage() {
return (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Header - responsive layout */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-4 flex-wrap">
<div>
<h1 className="text-3xl font-bold">Processes</h1>
<p className="text-muted-foreground mt-1">
<h1 className="text-2xl sm:text-3xl font-bold">Processes</h1>
<p className="text-sm md:text-base text-muted-foreground mt-1">
{displayedProcesses.length} of {processes?.length ?? 0} processes
{displayedProcesses.length !== (processes?.length ?? 0) && ' (filtered)'}
</p>
@@ -230,7 +231,9 @@ export default function ProcessesPage() {
onReconnect={reconnect}
/>
</div>
<div className="flex items-center gap-4">
{/* Controls - stack on mobile, row on desktop */}
<div className="flex flex-wrap items-center gap-2">
{viewMode === 'flat' && displayedProcesses.length > 0 && (
<Button
variant="outline"
@@ -239,13 +242,15 @@ export default function ProcessesPage() {
className="gap-2"
>
<CheckSquare className="h-4 w-4" />
{selectedProcesses.size === displayedProcesses.length ? 'Deselect All' : 'Select All'}
<span className="hidden sm:inline">
{selectedProcesses.size === displayedProcesses.length ? 'Deselect All' : 'Select All'}
</span>
</Button>
)}
<GroupSelector viewMode={viewMode} onViewModeChange={setViewMode} />
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
<span className="hidden sm:inline ml-2">Refresh</span>
</Button>
<Button
variant="outline"
@@ -265,17 +270,17 @@ export default function ProcessesPage() {
{/* Process Display */}
{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 className="flex flex-col items-center justify-center p-8 md:p-12 text-center border-2 border-dashed rounded-lg">
<p className="text-sm md:text-base text-muted-foreground">No processes configured</p>
</div>
) : displayedProcesses.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 match the current filters</p>
<div className="flex flex-col items-center justify-center p-8 md:p-12 text-center border-2 border-dashed rounded-lg">
<p className="text-sm md:text-base text-muted-foreground">No processes match the current filters</p>
</div>
) : viewMode === 'grouped' ? (
<GroupView processes={displayedProcesses} />
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 md:gap-6 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
{displayedProcesses.map((process, index) => {
const fullName = `${process.group}:${process.name}`;
const isFocused = index === focusedIndex;

View File

@@ -3,6 +3,7 @@
import { ProcessInfo, ProcessState } from '@/lib/supervisor/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { chartColors } from '@/lib/utils/chartColors';
interface GroupStatisticsProps {
processes: ProcessInfo[];
@@ -50,11 +51,19 @@ export function GroupStatistics({ processes }: GroupStatisticsProps) {
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
border: 'none',
borderRadius: '8px',
color: '#ffffff',
}}
itemStyle={{ color: '#ffffff' }}
/>
<Legend />
<Bar dataKey="running" stackId="a" fill="hsl(var(--success))" name="Running" />
<Bar dataKey="stopped" stackId="a" fill="hsl(var(--muted-foreground))" name="Stopped" />
<Bar dataKey="fatal" stackId="a" fill="hsl(var(--destructive))" name="Fatal" />
<Bar dataKey="running" stackId="a" fill={chartColors.running} name="Running" />
<Bar dataKey="stopped" stackId="a" fill={chartColors.stopped} name="Stopped" />
<Bar dataKey="fatal" stackId="a" fill={chartColors.fatal} name="Fatal" />
</BarChart>
</ResponsiveContainer>
</CardContent>

View File

@@ -3,6 +3,7 @@
import { ProcessInfo, ProcessState } from '@/lib/supervisor/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts';
import { chartColors } from '@/lib/utils/chartColors';
interface ProcessStateChartProps {
processes: ProcessInfo[];
@@ -23,12 +24,12 @@ export function ProcessStateChart({ processes }: ProcessStateChartProps) {
);
const data = [
{ name: 'Running', value: stateCounts.running, color: 'hsl(var(--success))' },
{ name: 'Stopped', value: stateCounts.stopped, color: 'hsl(var(--muted-foreground))' },
{ name: 'Fatal', value: stateCounts.fatal, color: 'hsl(var(--destructive))' },
{ name: 'Starting', value: stateCounts.starting, color: 'hsl(var(--warning))' },
{ name: 'Stopping', value: stateCounts.stopping, color: 'hsl(var(--accent))' },
{ name: 'Other', value: stateCounts.other, color: 'hsl(var(--muted))' },
{ name: 'Running', value: stateCounts.running, color: chartColors.running },
{ name: 'Stopped', value: stateCounts.stopped, color: chartColors.stopped },
{ name: 'Fatal', value: stateCounts.fatal, color: chartColors.fatal },
{ name: 'Starting', value: stateCounts.starting, color: chartColors.starting },
{ name: 'Stopping', value: stateCounts.stopping, color: chartColors.stopping },
{ name: 'Other', value: stateCounts.other, color: chartColors.muted },
].filter((item) => item.value > 0);
return (
@@ -53,7 +54,15 @@ export function ProcessStateChart({ processes }: ProcessStateChartProps) {
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
border: 'none',
borderRadius: '8px',
color: '#ffffff',
}}
itemStyle={{ color: '#ffffff' }}
/>
<Legend />
</PieChart>
</ResponsiveContainer>

View File

@@ -3,6 +3,7 @@
import { ProcessInfo, ProcessState } from '@/lib/supervisor/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { chartColors } from '@/lib/utils/chartColors';
interface ProcessUptimeChartProps {
processes: ProcessInfo[];
@@ -56,9 +57,16 @@ export function ProcessUptimeChart({ processes }: ProcessUptimeChartProps) {
const minutes = Math.floor((value - hours) * 60);
return `${hours}h ${minutes}m`;
}}
contentStyle={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
border: 'none',
borderRadius: '8px',
color: '#ffffff',
}}
itemStyle={{ color: '#ffffff' }}
/>
<Legend />
<Bar dataKey="uptime" fill="hsl(var(--success))" name="Uptime (hours)" />
<Bar dataKey="uptime" fill={chartColors.success} name="Uptime (hours)" />
</BarChart>
</ResponsiveContainer>
</CardContent>

View File

@@ -57,72 +57,115 @@ export function ConfigTable({ configs }: ConfigTableProps) {
};
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b">
<tr>
<th
className="text-left p-3 cursor-pointer hover:bg-muted transition-colors"
onClick={() => handleSort('group')}
>
Group <SortIcon field="group" />
</th>
<th
className="text-left p-3 cursor-pointer hover:bg-muted transition-colors"
onClick={() => handleSort('name')}
>
Name <SortIcon field="name" />
</th>
<th
className="text-left p-3 cursor-pointer hover:bg-muted transition-colors"
onClick={() => handleSort('command')}
>
Command <SortIcon field="command" />
</th>
<th className="text-left p-3">Directory</th>
<th
className="text-center p-3 cursor-pointer hover:bg-muted transition-colors"
onClick={() => handleSort('autostart')}
>
Autostart <SortIcon field="autostart" />
</th>
<th className="text-center p-3">Priority</th>
<th className="text-center p-3">Processes</th>
</tr>
</thead>
<tbody>
{sortedConfigs.map((config, index) => (
<tr
key={`${config.group}:${config.name}`}
className={cn(
'border-b hover:bg-muted/20 transition-colors',
index % 2 === 0 && 'bg-muted/5'
)}
>
<td className="p-3 font-medium">{config.group}</td>
<td className="p-3">{config.name}</td>
<td className="p-3">
<code className="text-xs bg-muted px-2 py-1 rounded">{config.command}</code>
</td>
<td className="p-3 text-sm text-muted-foreground">{config.directory}</td>
<td className="p-3 text-center">
<span
className={cn(
'inline-block w-3 h-3 rounded-full',
config.autostart ? 'bg-success' : 'bg-muted'
)}
/>
</td>
<td className="p-3 text-center text-sm">{config.priority}</td>
<td className="p-3 text-center text-sm">{config.numprocs}</td>
<>
{/* Desktop Table View - hidden on mobile */}
<Card className="hidden md:block">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b">
<tr>
<th
className="text-left p-3 cursor-pointer hover:bg-muted transition-colors"
onClick={() => handleSort('group')}
>
Group <SortIcon field="group" />
</th>
<th
className="text-left p-3 cursor-pointer hover:bg-muted transition-colors"
onClick={() => handleSort('name')}
>
Name <SortIcon field="name" />
</th>
<th
className="text-left p-3 cursor-pointer hover:bg-muted transition-colors"
onClick={() => handleSort('command')}
>
Command <SortIcon field="command" />
</th>
<th className="text-left p-3">Directory</th>
<th
className="text-center p-3 cursor-pointer hover:bg-muted transition-colors"
onClick={() => handleSort('autostart')}
>
Autostart <SortIcon field="autostart" />
</th>
<th className="text-center p-3">Priority</th>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</thead>
<tbody>
{sortedConfigs.map((config, index) => (
<tr
key={`${config.group}:${config.name}`}
className={cn(
'border-b hover:bg-muted/20 transition-colors',
index % 2 === 0 && 'bg-muted/5'
)}
>
<td className="p-3 font-medium">{config.group}</td>
<td className="p-3">{config.name}</td>
<td className="p-3">
<code className="text-xs bg-muted px-2 py-1 rounded">{config.command}</code>
</td>
<td className="p-3 text-sm text-muted-foreground">{config.directory}</td>
<td className="p-3 text-center">
<span
className={cn(
'inline-block w-3 h-3 rounded-full',
config.autostart ? 'bg-success' : 'bg-muted'
)}
/>
</td>
<td className="p-3 text-center text-sm">{config.process_prio}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Mobile Card View - visible on mobile only */}
<div className="md:hidden space-y-4">
{sortedConfigs.map((config) => (
<Card key={`${config.group}:${config.name}`}>
<CardContent className="space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="font-medium text-lg">{config.name}</div>
<div className="text-sm text-muted-foreground">{config.group}</div>
</div>
<span
className={cn(
'inline-block w-3 h-3 rounded-full mt-1',
config.autostart ? 'bg-success' : 'bg-muted'
)}
title={config.autostart ? 'Autostart enabled' : 'Autostart disabled'}
/>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">Command:</span>
<code className="block mt-1 text-xs bg-muted px-2 py-1 rounded break-all">
{config.command}
</code>
</div>
<div>
<span className="text-muted-foreground">Directory:</span>
<div className="mt-1 text-xs break-all">{config.directory}</div>
</div>
<div className="pt-2">
<span className="text-muted-foreground">Priority:</span>
<span className="ml-2 font-mono">{config.process_prio}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</>
);
}

View File

@@ -49,7 +49,7 @@ export function GroupCard({ groupName, processes }: GroupCardProps) {
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex flex-col md:flex-row items-start md:items-center gap-3 md:justify-between">
<div className="flex items-center gap-3 flex-1">
<Button
variant="ghost"
@@ -80,13 +80,13 @@ export function GroupCard({ groupName, processes }: GroupCardProps) {
</div>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Button
variant="success"
size="sm"
onClick={handleStart}
disabled={isLoading || stats.stopped === 0}
className="gap-2"
className="gap-2 flex-1 sm:flex-initial"
>
<Play className="h-4 w-4" />
Start All
@@ -96,7 +96,7 @@ export function GroupCard({ groupName, processes }: GroupCardProps) {
size="sm"
onClick={handleStop}
disabled={isLoading || stats.running === 0}
className="gap-2"
className="gap-2 flex-1 sm:flex-initial"
>
<Square className="h-4 w-4" />
Stop All
@@ -106,7 +106,7 @@ export function GroupCard({ groupName, processes }: GroupCardProps) {
size="sm"
onClick={handleRestart}
disabled={isLoading}
className="gap-2"
className="gap-2 flex-1 sm:flex-initial"
>
<RotateCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
Restart All

View File

@@ -5,9 +5,11 @@ import { Navbar } from './Navbar';
export function AppLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen">
<div className="flex flex-col min-h-screen">
<Navbar />
<main className="flex-1 container mx-auto px-4 py-8">{children}</main>
<main className="flex-1 container mx-auto px-4 md:px-6 lg:px-8 py-4 md:py-6 lg:py-8">
{children}
</main>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Moon, Sun, Activity } from 'lucide-react';
import { Moon, Sun, Activity, Menu, X } from 'lucide-react';
import { useTheme } from '@/components/providers/ThemeProvider';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
@@ -19,12 +19,30 @@ const navItems = [
export function Navbar() {
const pathname = usePathname();
const [mounted, setMounted] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const themeContext = useTheme();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
// Close mobile menu when route changes
setMobileMenuOpen(false);
}, [pathname]);
useEffect(() => {
// Prevent scroll when mobile menu is open
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [mobileMenuOpen]);
const toggleTheme = () => {
if (themeContext) {
themeContext.setTheme(themeContext.resolvedTheme === 'dark' ? 'light' : 'dark');
@@ -33,17 +51,17 @@ export function Navbar() {
return (
<nav className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center px-4">
<div className="container mx-auto flex h-16 items-center px-4 md:px-6">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 mr-8">
<Link href="/" className="flex items-center gap-2 mr-4 md:mr-8">
<Activity className="h-6 w-6 text-primary" />
<span className="font-bold text-xl bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
<span className="font-bold text-lg md:text-xl bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
Supervisor
</span>
</Link>
{/* Navigation Links */}
<div className="flex gap-1 flex-1">
{/* Desktop Navigation Links */}
<div className="hidden md:flex gap-1 flex-1">
{navItems.map((item) => (
<Link key={item.href} href={item.href}>
<Button
@@ -60,22 +78,65 @@ export function Navbar() {
))}
</div>
{/* Theme Toggle */}
{mounted && themeContext && (
{/* Right side buttons */}
<div className="flex items-center gap-2 ml-auto">
{/* Theme Toggle */}
{mounted && themeContext && (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label="Toggle theme"
className="h-10 w-10"
>
{themeContext.resolvedTheme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
)}
{/* Mobile Menu Button */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label="Toggle theme"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
className="md:hidden h-10 w-10"
>
{themeContext.resolvedTheme === 'dark' ? (
<Sun className="h-5 w-5" />
{mobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Moon className="h-5 w-5" />
<Menu className="h-6 w-6" />
)}
</Button>
)}
</div>
</div>
{/* Mobile Menu Drawer */}
{mobileMenuOpen && (
<div className="md:hidden fixed inset-0 top-16 z-50">
<div className="container">
<nav className="flex flex-col gap-2 bg-background/95 backdrop-blur-md rounded-lg p-4">
{navItems.map((item) => (
<Link key={item.href} href={item.href}>
<Button
variant="ghost"
size="lg"
className={cn(
'w-full justify-start text-lg transition-colors',
pathname === item.href && 'bg-accent text-accent-foreground'
)}
>
{item.label}
</Button>
</Link>
))}
</nav>
</div>
</div>
)}
</nav>
);
}

View File

@@ -26,7 +26,7 @@ export function LogControls({
isClearing = false,
}: LogControlsProps) {
return (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={isPlaying ? 'destructive' : 'success'}
size="sm"

View File

@@ -12,7 +12,7 @@ interface LogSearchProps {
export function LogSearch({ value, onChange, placeholder = 'Search logs...' }: LogSearchProps) {
return (
<div className="relative flex-1 max-w-md">
<div className="relative w-full sm:flex-1 sm:max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"

View File

@@ -46,60 +46,74 @@ export function BatchActions({ selectedProcesses, processes, onClearSelection }:
};
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-slide-up">
<div className="fixed bottom-4 left-4 right-4 md:left-1/2 md:right-auto md:-translate-x-1/2 z-50 animate-slide-up">
<Card className="shadow-2xl border-2">
<div className="flex items-center gap-4 px-6 py-4">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-sm font-bold text-primary">{selectedCount}</span>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4 p-4">
{/* Selection count */}
<div className="flex items-center justify-between sm:justify-start gap-2">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-sm font-bold text-primary">{selectedCount}</span>
</div>
<span className="text-sm font-medium">
{selectedCount} {selectedCount === 1 ? 'process' : 'processes'} selected
</span>
</div>
<span className="text-sm font-medium">
{selectedCount} {selectedCount === 1 ? 'process' : 'processes'} selected
</span>
{/* Close button on mobile - inline with count */}
<Button
variant="ghost"
size="icon"
onClick={onClearSelection}
className="h-8 w-8 sm:hidden"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="h-6 w-px bg-border" />
<div className="hidden sm:block h-6 w-px bg-border" />
<div className="flex gap-2">
{/* Action buttons - full width on mobile */}
<div className="flex gap-2 flex-1">
<Button
variant="success"
size="sm"
onClick={handleStartSelected}
disabled={isLoading}
className="gap-2"
className="gap-2 flex-1 sm:flex-initial"
>
<Play className="h-4 w-4" />
Start Selected
<span>Start</span>
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleStopSelected}
disabled={isLoading}
className="gap-2"
className="gap-2 flex-1 sm:flex-initial"
>
<Square className="h-4 w-4" />
Stop Selected
<span>Stop</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestartSelected}
disabled={isLoading}
className="gap-2"
className="gap-2 flex-1 sm:flex-initial"
>
<RotateCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
Restart Selected
<span>Restart</span>
</Button>
</div>
<div className="h-6 w-px bg-border" />
<div className="hidden sm:block h-6 w-px bg-border" />
{/* Close button on desktop */}
<Button
variant="ghost"
size="icon"
onClick={onClearSelection}
className="h-8 w-8"
className="hidden sm:flex h-8 w-8"
>
<X className="h-4 w-4" />
</Button>

View File

@@ -42,7 +42,7 @@ export function ProcessCard({ process, isSelected = false, isFocused = false, on
return (
<Card
className={cn(
'transition-all hover:shadow-lg animate-fade-in',
'transition-all hover:shadow-lg animate-fade-in h-full flex flex-col',
onSelectionChange && 'cursor-pointer',
isSelected && 'ring-2 ring-primary ring-offset-2',
isFocused && 'ring-2 ring-accent ring-offset-2 shadow-xl'
@@ -82,7 +82,7 @@ export function ProcessCard({ process, isSelected = false, isFocused = false, on
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-4 flex-1 flex flex-col justify-between">
{/* Metrics */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
@@ -109,34 +109,37 @@ export function ProcessCard({ process, isSelected = false, isFocused = false, on
)}
{/* Actions */}
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-wrap gap-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="success"
onClick={handleStart}
disabled={!canStartProcess(process.state as ProcessStateCode) || isLoading}
className="flex-1"
className="flex-1 min-w-[100px]"
>
<Play className="h-4 w-4" />
Start
<span className="hidden sm:inline">Start</span>
</Button>
<Button
size="sm"
variant="warning"
onClick={handleStop}
disabled={!canStopProcess(process.state as ProcessStateCode) || isLoading}
className="flex-1"
className="flex-1 min-w-[100px]"
>
<Square className="h-4 w-4" />
Stop
<span className="hidden sm:inline">Stop</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={process.state === 0 || isLoading}
title="Restart"
className="flex-1 sm:flex-initial"
>
<RotateCw className={cn('h-4 w-4', isLoading && 'animate-spin-slow')} />
<span className="sm:hidden">Restart</span>
</Button>
<Button
size="sm"
@@ -144,8 +147,10 @@ export function ProcessCard({ process, isSelected = false, isFocused = false, on
onClick={() => setShowSignalModal(true)}
disabled={process.state === 0}
title="Send Signal"
className="flex-1 sm:flex-initial"
>
<Zap className="h-4 w-4" />
<span className="sm:hidden">Signal</span>
</Button>
<Button
size="sm"
@@ -153,8 +158,10 @@ export function ProcessCard({ process, isSelected = false, isFocused = false, on
onClick={() => setShowStdinModal(true)}
disabled={process.state !== 20}
title="Send Stdin"
className="flex-1 sm:flex-initial"
>
<Terminal className="h-4 w-4" />
<span className="sm:hidden">Stdin</span>
</Button>
</div>

View File

@@ -54,7 +54,7 @@ export function SignalSender({ processName, onClose }: SignalSenderProps) {
const isDangerous = signal && ['TERM', 'KILL', 'QUIT'].includes(signal.toUpperCase());
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<Card className="w-full max-w-lg shadow-2xl">
<CardHeader>
<div className="flex items-center justify-between">

View File

@@ -42,7 +42,7 @@ export function StdinInput({ processName, onClose }: StdinInputProps) {
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<Card className="w-full max-w-2xl shadow-2xl">
<CardHeader>
<div className="flex items-center justify-between">

View File

@@ -0,0 +1,163 @@
'use client';
import { Component, ErrorInfo, ReactNode } from 'react';
import { clientLogger } from '@/lib/utils/client-logger';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error, errorInfo: ErrorInfo, reset: () => void) => ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
/**
* Error Boundary component that catches React errors and logs them
* Provides a fallback UI when errors occur
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Log error to client logger
clientLogger.error('React Error Boundary caught error', error, {
componentStack: errorInfo.componentStack,
errorBoundary: 'ErrorBoundary',
});
// Store error info in state
this.setState({
errorInfo,
});
// Send error to server for logging (async, non-blocking)
this.reportErrorToServer(error, errorInfo).catch((reportError) => {
clientLogger.error('Failed to report error to server', reportError);
});
}
private async reportErrorToServer(error: Error, errorInfo: ErrorInfo): Promise<void> {
try {
await fetch('/api/client-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
name: error.name,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
url: typeof window !== 'undefined' ? window.location.href : 'unknown',
timestamp: new Date().toISOString(),
}),
});
} catch (err) {
// Silently fail - we don't want error reporting to cause more errors
console.error('Error reporting failed:', err);
}
}
private handleReset = (): void => {
clientLogger.info('Error boundary reset requested');
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render(): ReactNode {
if (this.state.hasError && this.state.error) {
// Use custom fallback if provided
if (this.props.fallback) {
return this.props.fallback(
this.state.error,
this.state.errorInfo!,
this.handleReset
);
}
// Default fallback UI
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 dark:bg-red-900/20 rounded-full mb-4">
<svg
className="w-6 h-6 text-red-600 dark:text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white text-center mb-2">
Something went wrong
</h2>
<p className="text-gray-600 dark:text-gray-400 text-center mb-4">
An unexpected error occurred. The error has been logged and we&apos;ll look into it.
</p>
{process.env.NODE_ENV === 'development' && this.state.error && (
<div className="mb-4 p-4 bg-gray-100 dark:bg-gray-900 rounded-md">
<p className="text-sm font-mono text-red-600 dark:text-red-400 break-all">
{this.state.error.message}
</p>
{this.state.error.stack && (
<details className="mt-2">
<summary className="text-xs text-gray-600 dark:text-gray-400 cursor-pointer">
Stack trace
</summary>
<pre className="mt-2 text-xs text-gray-600 dark:text-gray-400 overflow-x-auto">
{this.state.error.stack}
</pre>
</details>
)}
</div>
)}
<button
onClick={this.handleReset}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="w-full mt-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-md transition-colors"
>
Reload page
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,9 +1,12 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, ReactNode } from 'react';
import { useState, ReactNode, useEffect } from 'react';
import { Toaster } from 'sonner';
import { ThemeProvider } from './ThemeProvider';
import { ErrorBoundary } from './ErrorBoundary';
import { clientLogger } from '@/lib/utils/client-logger';
import { initGlobalErrorHandlers } from '@/lib/utils/global-error-handler';
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
@@ -15,16 +18,26 @@ export function Providers({ children }: { children: ReactNode }) {
refetchOnWindowFocus: false,
retry: 2,
},
mutations: {
retry: 1,
},
},
})
);
// Initialize global error handlers once
useEffect(() => {
initGlobalErrorHandlers();
}, []);
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" richColors closeButton />
</QueryClientProvider>
</ThemeProvider>
<ErrorBoundary>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" richColors closeButton />
</QueryClientProvider>
</ThemeProvider>
</ErrorBoundary>
);
}

View File

@@ -47,7 +47,7 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
export function KeyboardShortcutsHelp({ onClose }: KeyboardShortcutsHelpProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<Card className="w-full max-w-2xl shadow-2xl max-h-[80vh] overflow-y-auto">
<CardHeader>
<div className="flex items-center justify-between">

View File

@@ -26,10 +26,10 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
variant === 'outline',
},
{
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4': size === 'md',
'h-9 md:h-8 px-3 text-sm': size === 'sm',
'h-11 md:h-10 px-4': size === 'md',
'h-12 px-6 text-lg': size === 'lg',
'h-10 w-10': size === 'icon',
'h-11 w-11 md:h-10 md:w-10': size === 'icon',
},
className
)}

View File

@@ -19,7 +19,7 @@ const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
className={cn('flex flex-col space-y-1.5 p-4 md:p-6', className)}
{...props}
/>
)
@@ -30,7 +30,7 @@ const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingEle
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
className={cn('text-xl md:text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
@@ -50,7 +50,7 @@ CardDescription.displayName = 'CardDescription';
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
<div ref={ref} className={cn('p-4 md:p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
@@ -59,7 +59,7 @@ const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
className={cn('flex items-center p-4 md:p-6 pt-0', className)}
{...props}
/>
)

View File

@@ -1,4 +1,7 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { createHookLogger } from '@/lib/utils/client-logger';
const logger = createHookLogger('useEventSource');
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
@@ -36,6 +39,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {})
const connect = useCallback(() => {
if (!enabled || eventSourceRef.current) return;
logger.info('Connecting to SSE', { url });
setStatus('connecting');
try {
@@ -43,6 +47,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {})
eventSourceRef.current = eventSource;
eventSource.addEventListener('connected', () => {
logger.info('SSE connected successfully', { url });
setStatus('connected');
setReconnectAttempts(0);
onConnect?.();
@@ -51,6 +56,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {})
eventSource.addEventListener('heartbeat', (event) => {
// Keep connection alive
if (status !== 'connected') {
logger.debug('SSE heartbeat received, updating status to connected');
setStatus('connected');
}
});
@@ -58,15 +64,20 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {})
eventSource.addEventListener('process-update', (event) => {
try {
const data = JSON.parse(event.data);
logger.debug('Process update received', {
processCount: data.processes?.length,
timestamp: data.timestamp,
});
onMessage?.({ event: 'process-update', data });
} catch (error) {
console.error('Failed to parse SSE message:', error);
logger.error('Failed to parse SSE message', error, { event: event.data });
}
});
eventSource.addEventListener('error', (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
logger.warn('SSE error event received', { error: data });
onMessage?.({ event: 'error', data });
} catch (error) {
// Not a message error, connection error
@@ -74,7 +85,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {})
});
eventSource.onerror = (event) => {
console.error('EventSource error:', event);
logger.error('EventSource connection error', event);
setStatus('error');
onError?.(event);
@@ -85,24 +96,33 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {})
// Attempt reconnection with exponential backoff
if (reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(reconnectInterval * Math.pow(2, reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`);
logger.info('Scheduling reconnection', {
delay,
attempt: reconnectAttempts + 1,
maxAttempts: maxReconnectAttempts,
});
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempts((prev) => prev + 1);
connect();
}, delay);
} else {
logger.warn('Max reconnection attempts reached, disconnecting', {
maxAttempts: maxReconnectAttempts,
});
setStatus('disconnected');
onDisconnect?.();
}
};
} catch (error) {
console.error('Failed to create EventSource:', error);
logger.error('Failed to create EventSource', error, { url });
setStatus('error');
}
}, [url, enabled, status, reconnectAttempts, maxReconnectAttempts, reconnectInterval, onMessage, onError, onConnect, onDisconnect]);
const disconnect = useCallback(() => {
logger.info('Disconnecting from SSE');
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
@@ -118,6 +138,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {})
}, []);
const reconnect = useCallback(() => {
logger.info('Manual reconnection requested');
disconnect();
setReconnectAttempts(0);
connect();

View File

@@ -3,6 +3,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import type { ProcessInfo, SystemInfo, LogTailResult, ConfigInfo } from '@/lib/supervisor/types';
import { createMutationLogger } from '@/lib/utils/client-logger';
// Query Keys
export const supervisorKeys = {
@@ -143,16 +144,21 @@ export function useProcessLogs(
export function useStartProcess() {
const queryClient = useQueryClient();
const logger = createMutationLogger('startProcess');
return useMutation({
mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => startProcess(name, wait),
mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => {
logger.info('Starting process', { name, wait });
return startProcess(name, wait);
},
onSuccess: (data, variables) => {
logger.info('Process started successfully', { name: variables.name });
toast.success(data.message);
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
queryClient.invalidateQueries({ queryKey: supervisorKeys.process(variables.name) });
},
onError: (error: Error) => {
onError: (error: Error, variables) => {
logger.error('Failed to start process', error, { name: variables.name });
toast.error(`Failed to start process: ${error.message}`);
},
});
@@ -160,15 +166,21 @@ export function useStartProcess() {
export function useStopProcess() {
const queryClient = useQueryClient();
const logger = createMutationLogger('stopProcess');
return useMutation({
mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => stopProcess(name, wait),
mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => {
logger.info('Stopping process', { name, wait });
return stopProcess(name, wait);
},
onSuccess: (data, variables) => {
logger.info('Process stopped successfully', { name: variables.name });
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
queryClient.invalidateQueries({ queryKey: supervisorKeys.process(variables.name) });
},
onError: (error: Error) => {
onError: (error: Error, variables) => {
logger.error('Failed to stop process', error, { name: variables.name });
toast.error(`Failed to stop process: ${error.message}`);
},
});
@@ -176,15 +188,21 @@ export function useStopProcess() {
export function useRestartProcess() {
const queryClient = useQueryClient();
const logger = createMutationLogger('restartProcess');
return useMutation({
mutationFn: (name: string) => restartProcess(name),
mutationFn: (name: string) => {
logger.info('Restarting process', { name });
return restartProcess(name);
},
onSuccess: (data, name) => {
logger.info('Process restarted successfully', { name });
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
queryClient.invalidateQueries({ queryKey: supervisorKeys.process(name) });
},
onError: (error: Error) => {
onError: (error: Error, name) => {
logger.error('Failed to restart process', error, { name });
toast.error(`Failed to restart process: ${error.message}`);
},
});
@@ -192,7 +210,7 @@ export function useRestartProcess() {
// Log Management
async function fetchMainLog(offset: number = -4096, length: number = 4096): Promise<{ logs: string }> {
async function fetchMainLog(offset: number = -4096, length: number = 0): Promise<{ logs: string }> {
const response = await fetch(`/api/supervisor/logs?offset=${offset}&length=${length}`);
if (!response.ok) {
const error = await response.json();
@@ -415,14 +433,22 @@ async function restartAllProcesses(wait: boolean = true): Promise<{ success: boo
export function useStartAllProcesses() {
const queryClient = useQueryClient();
const logger = createMutationLogger('startAllProcesses');
return useMutation({
mutationFn: (wait: boolean = true) => startAllProcesses(wait),
mutationFn: (wait: boolean = true) => {
logger.info('Starting all processes', { wait });
return startAllProcesses(wait);
},
onSuccess: (data) => {
logger.info('All processes started successfully', {
resultCount: data.results?.length,
});
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
},
onError: (error: Error) => {
logger.error('Failed to start all processes', error);
toast.error(`Failed to start all processes: ${error.message}`);
},
});
@@ -430,14 +456,22 @@ export function useStartAllProcesses() {
export function useStopAllProcesses() {
const queryClient = useQueryClient();
const logger = createMutationLogger('stopAllProcesses');
return useMutation({
mutationFn: (wait: boolean = true) => stopAllProcesses(wait),
mutationFn: (wait: boolean = true) => {
logger.info('Stopping all processes', { wait });
return stopAllProcesses(wait);
},
onSuccess: (data) => {
logger.info('All processes stopped successfully', {
resultCount: data.results?.length,
});
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
},
onError: (error: Error) => {
logger.error('Failed to stop all processes', error);
toast.error(`Failed to stop all processes: ${error.message}`);
},
});
@@ -445,14 +479,22 @@ export function useStopAllProcesses() {
export function useRestartAllProcesses() {
const queryClient = useQueryClient();
const logger = createMutationLogger('restartAllProcesses');
return useMutation({
mutationFn: (wait: boolean = true) => restartAllProcesses(wait),
mutationFn: (wait: boolean = true) => {
logger.info('Restarting all processes', { wait });
return restartAllProcesses(wait);
},
onSuccess: (data) => {
logger.info('All processes restarted successfully', {
resultCount: data.results?.length,
});
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
},
onError: (error: Error) => {
logger.error('Failed to restart all processes', error);
toast.error(`Failed to restart all processes: ${error.message}`);
},
});
@@ -516,14 +558,20 @@ export function useConfig() {
export function useReloadConfig() {
const queryClient = useQueryClient();
const logger = createMutationLogger('reloadConfig');
return useMutation({
mutationFn: reloadConfig,
mutationFn: () => {
logger.info('Reloading supervisor configuration');
return reloadConfig();
},
onSuccess: (data) => {
logger.info('Configuration reloaded successfully');
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.all });
},
onError: (error: Error) => {
logger.error('Failed to reload configuration', error);
toast.error(`Failed to reload configuration: ${error.message}`);
},
});
@@ -602,15 +650,24 @@ async function signalAllProcesses(signal: string): Promise<{ success: boolean; m
export function useSignalProcess() {
const queryClient = useQueryClient();
const logger = createMutationLogger('signalProcess');
return useMutation({
mutationFn: ({ name, signal }: { name: string; signal: string }) => signalProcess(name, signal),
mutationFn: ({ name, signal }: { name: string; signal: string }) => {
logger.info('Sending signal to process', { name, signal });
return signalProcess(name, signal);
},
onSuccess: (data, variables) => {
logger.info('Signal sent successfully', { name: variables.name, signal: variables.signal });
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.process(variables.name) });
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
},
onError: (error: Error) => {
onError: (error: Error, variables) => {
logger.error('Failed to send signal', error, {
name: variables.name,
signal: variables.signal,
});
toast.error(`Failed to send signal: ${error.message}`);
},
});

View File

@@ -62,38 +62,39 @@ export const ConfigInfoSchema = z.object({
name: z.string(),
group: z.string(),
autostart: z.boolean(),
directory: z.union([z.string(), z.null()]),
autorestart: z.string().optional(), // "auto", "none", or "unexpected"
directory: z.string(),
command: z.string(),
environment: z.union([z.string(), z.null()]),
exitcodes: z.array(z.number()),
group_prio: z.number(),
inuse: z.boolean(),
killasgroup: z.boolean(),
process_prio: z.number(),
redirect_stderr: z.boolean(),
serverurl: z.string(),
startretries: z.number(),
startsecs: z.number(),
stderr_capture_maxbytes: z.number(),
stderr_events_enabled: z.boolean(),
stderr_logfile: z.string(),
stderr_logfile_backups: z.number(),
stderr_logfile_maxbytes: z.number(),
stderr_syslog: z.boolean(),
stopsignal: z.number(), // Signal number (e.g., 15 for SIGTERM)
stopwaitsecs: z.number(),
stdout_capture_maxbytes: z.number(),
stdout_events_enabled: z.boolean(),
stdout_logfile: z.string(),
stdout_logfile_backups: z.number(),
stdout_logfile_maxbytes: z.number(),
stopsignal: z.string(),
stopwaitsecs: z.number(),
priority: z.number(),
startretries: z.number(),
startsecs: z.number(),
process_name: z.string(),
numprocs: z.number(),
numprocs_start: z.number(),
uid: z.union([z.number(), z.null()]),
username: z.union([z.string(), z.null()]),
inuse: z.boolean(),
stdout_syslog: z.boolean(),
uid: z.string(), // Username string
});
export const ReloadConfigResultSchema = z.object({
added: z.array(z.array(z.string())),
changed: z.array(z.array(z.string())),
removed: z.array(z.array(z.string())),
added: z.array(z.array(z.string())).optional().default([]),
changed: z.array(z.array(z.string())).optional().default([]),
removed: z.array(z.array(z.string())).optional().default([]),
});
// TypeScript Types

53
lib/utils/chartColors.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Chart color utilities for Recharts compatibility
*
* Recharts doesn't properly parse CSS custom properties or OKLCH colors,
* so we provide static hex colors that work reliably across themes.
*/
export const chartColors = {
primary: '#3b82f6', // Blue
success: '#22c55e', // Green
warning: '#eab308', // Yellow
destructive: '#ef4444', // Red
accent: '#06b6d4', // Cyan
muted: '#9ca3af', // Gray
running: '#22c55e', // Same as success
stopped: '#6b7280', // Darker gray
fatal: '#ef4444', // Same as destructive
starting: '#eab308', // Same as warning
backoff: '#f97316', // Orange
stopping: '#fb923c', // Light orange
exited: '#9ca3af', // Same as muted
unknown: '#64748b', // Slate
};
/**
* Get color for process state
*/
export function getStateColor(state: string): string {
const stateMap: Record<string, string> = {
running: chartColors.running,
stopped: chartColors.stopped,
fatal: chartColors.fatal,
starting: chartColors.starting,
backoff: chartColors.backoff,
stopping: chartColors.stopping,
exited: chartColors.exited,
unknown: chartColors.unknown,
};
return stateMap[state.toLowerCase()] || chartColors.muted;
}
/**
* Color palette for multiple data series
*/
export const colorPalette = [
chartColors.primary,
chartColors.success,
chartColors.warning,
chartColors.accent,
chartColors.destructive,
chartColors.muted,
];

137
lib/utils/client-logger.ts Normal file
View File

@@ -0,0 +1,137 @@
'use client';
/**
* Client-side logging utility
* Logs to console in development, can be extended to send to server in production
*/
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogContext {
[key: string]: any;
}
class ClientLogger {
private isDevelopment: boolean;
constructor() {
this.isDevelopment = process.env.NODE_ENV === 'development';
}
private formatMessage(level: LogLevel, message: string, context?: LogContext): string {
const timestamp = new Date().toISOString();
const contextStr = context ? ` ${JSON.stringify(context)}` : '';
return `[${timestamp}] [${level.toUpperCase()}] ${message}${contextStr}`;
}
private shouldLog(level: LogLevel): boolean {
// In production, only log warnings and errors
if (!this.isDevelopment && (level === 'debug' || level === 'info')) {
return false;
}
return true;
}
debug(message: string, context?: LogContext): void {
if (this.shouldLog('debug')) {
console.debug(this.formatMessage('debug', message, context));
}
}
info(message: string, context?: LogContext): void {
if (this.shouldLog('info')) {
console.info(this.formatMessage('info', message, context));
}
}
warn(message: string, context?: LogContext): void {
if (this.shouldLog('warn')) {
console.warn(this.formatMessage('warn', message, context));
}
}
error(message: string, error?: Error | unknown, context?: LogContext): void {
if (this.shouldLog('error')) {
const errorContext = {
...context,
error: error instanceof Error ? {
message: error.message,
stack: error.stack,
name: error.name,
} : error,
};
console.error(this.formatMessage('error', message, errorContext));
}
}
// Performance timing helper
time(label: string): void {
if (this.isDevelopment) {
console.time(label);
}
}
timeEnd(label: string): void {
if (this.isDevelopment) {
console.timeEnd(label);
}
}
// Group logging for related operations
group(label: string): void {
if (this.isDevelopment) {
console.group(label);
}
}
groupEnd(): void {
if (this.isDevelopment) {
console.groupEnd();
}
}
}
// Export singleton instance
export const clientLogger = new ClientLogger();
// Hook-specific logger factory
export function createHookLogger(hookName: string) {
return {
debug: (message: string, context?: LogContext) =>
clientLogger.debug(`[${hookName}] ${message}`, context),
info: (message: string, context?: LogContext) =>
clientLogger.info(`[${hookName}] ${message}`, context),
warn: (message: string, context?: LogContext) =>
clientLogger.warn(`[${hookName}] ${message}`, context),
error: (message: string, error?: Error | unknown, context?: LogContext) =>
clientLogger.error(`[${hookName}] ${message}`, error, context),
};
}
// Query/Mutation logger factory
export function createQueryLogger(queryKey: readonly unknown[]) {
const key = JSON.stringify(queryKey);
return {
debug: (message: string, context?: LogContext) =>
clientLogger.debug(`[Query: ${key}] ${message}`, context),
info: (message: string, context?: LogContext) =>
clientLogger.info(`[Query: ${key}] ${message}`, context),
warn: (message: string, context?: LogContext) =>
clientLogger.warn(`[Query: ${key}] ${message}`, context),
error: (message: string, error?: Error | unknown, context?: LogContext) =>
clientLogger.error(`[Query: ${key}] ${message}`, error, context),
};
}
export function createMutationLogger(mutationKey: string) {
return {
debug: (message: string, context?: LogContext) =>
clientLogger.debug(`[Mutation: ${mutationKey}] ${message}`, context),
info: (message: string, context?: LogContext) =>
clientLogger.info(`[Mutation: ${mutationKey}] ${message}`, context),
warn: (message: string, context?: LogContext) =>
clientLogger.warn(`[Mutation: ${mutationKey}] ${message}`, context),
error: (message: string, error?: Error | unknown, context?: LogContext) =>
clientLogger.error(`[Mutation: ${mutationKey}] ${message}`, error, context),
};
}

View File

@@ -0,0 +1,108 @@
'use client';
import { clientLogger } from './client-logger';
/**
* Global error handler for unhandled errors and promise rejections
* Sets up event listeners for window errors and unhandled promise rejections
*/
interface ClientErrorReport {
message: string;
stack?: string;
url: string;
userAgent: string;
timestamp: string;
type: 'error' | 'unhandledrejection';
filename?: string;
lineno?: number;
colno?: number;
}
async function reportErrorToServer(errorReport: ClientErrorReport): Promise<void> {
try {
await fetch('/api/client-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorReport),
});
} catch (err) {
// Silently fail - don't want error reporting to cause more errors
console.error('Failed to report error to server:', err);
}
}
/**
* Initialize global error handlers
* Should be called once when the app starts
*/
export function initGlobalErrorHandlers(): void {
if (typeof window === 'undefined') {
return; // Only run in browser
}
// Handle uncaught errors
window.addEventListener('error', (event: ErrorEvent) => {
clientLogger.error('Uncaught error', event.error, {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
// Report to server
reportErrorToServer({
message: event.message,
stack: event.error?.stack,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
type: 'error',
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
}).catch(() => {
// Ignore reporting errors
});
});
// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
const reason = event.reason;
const message = reason instanceof Error ? reason.message : String(reason);
const stack = reason instanceof Error ? reason.stack : undefined;
clientLogger.error('Unhandled promise rejection', reason, {
message,
stack,
});
// Report to server
reportErrorToServer({
message: `Unhandled Promise Rejection: ${message}`,
stack,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
type: 'unhandledrejection',
}).catch(() => {
// Ignore reporting errors
});
});
clientLogger.info('Global error handlers initialized');
}
/**
* Cleanup global error handlers
*/
export function cleanupGlobalErrorHandlers(): void {
if (typeof window === 'undefined') {
return;
}
// Remove event listeners
// Note: We can't remove the exact listeners without keeping references
// This is mainly for completeness, in practice these stay for the lifetime of the app
clientLogger.info('Global error handlers cleanup requested');
}

View File

@@ -5,9 +5,7 @@ const nextConfig: NextConfig = {
output: 'standalone',
// Turbopack configuration (Next.js 16+)
turbopack: {},
experimental: {
serverComponentsExternalPackages: ['pino', 'pino-pretty'],
},
serverExternalPackages: ['pino', 'pino-pretty'],
};
export default nextConfig;