Compare commits

...

5 Commits

Author SHA1 Message Date
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
48 changed files with 1265 additions and 693 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, generateRequestId } from '@/lib/utils/api-logger';
import { formatError } 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') || '4096', 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">
{displayedProcesses.map((process, index) => {
const fullName = `${process.group}:${process.name}`;
const isFocused = index === focusedIndex;

View File

@@ -57,72 +57,123 @@ 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>
<th className="text-center p-3">Processes</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.priority}</td>
<td className="p-3 text-center text-sm">{config.numprocs}</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="flex gap-4 pt-2">
<div>
<span className="text-muted-foreground">Priority:</span>
<span className="ml-2 font-mono">{config.priority}</span>
</div>
<div>
<span className="text-muted-foreground">Processes:</span>
<span className="ml-2 font-mono">{config.numprocs}</span>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</>
);
}

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 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 bg-background/95 backdrop-blur-sm">
<div className="container px-4 py-6">
<nav className="flex flex-col gap-2">
{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

@@ -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 className="hidden sm:inline">Start Selected</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 className="hidden sm:inline">Stop Selected</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 className="hidden sm:inline">Restart Selected</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

@@ -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,31 @@ export function Providers({ children }: { children: ReactNode }) {
refetchOnWindowFocus: false,
retry: 2,
},
mutations: {
retry: 1,
},
},
logger: {
log: (message) => clientLogger.debug(message),
warn: (message) => clientLogger.warn(message),
error: (error) => clientLogger.error('React Query error', error),
},
})
);
// 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}`);
},
});
@@ -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}`);
},
});

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;