feat: initial commit - Supervisor UI with Next.js 16 and Tailwind CSS 4
Some checks failed
Build and Push Docker Image to Gitea / build-and-push (push) Failing after 1m22s

- Modern web interface for Supervisor process management
- Built with Next.js 16 (App Router) and Tailwind CSS 4
- Full XML-RPC client implementation for Supervisor API
- Real-time process monitoring with auto-refresh
- Process control: start, stop, restart operations
- Modern dashboard with system status and statistics
- Dark/light theme with OKLCH color system
- Docker multi-stage build with runtime env var configuration
- Gitea CI/CD workflow for automated builds
- Comprehensive documentation (README, IMPLEMENTATION, DEPLOYMENT)

Features:
- Backend proxy pattern for secure API communication
- React Query for state management and caching
- TypeScript strict mode with Zod validation
- Responsive design with mobile support
- Health check endpoint for monitoring
- Non-root user security in Docker

Environment Variables:
- SUPERVISOR_HOST, SUPERVISOR_PORT
- SUPERVISOR_USERNAME, SUPERVISOR_PASSWORD (optional)
- Configurable at build-time and runtime

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-23 18:23:51 +01:00
commit e0cfd371c0
44 changed files with 8504 additions and 0 deletions

59
.dockerignore Normal file
View File

@@ -0,0 +1,59 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build output
.next
out
dist
build
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
*~
# Environment files
.env
.env.local
.env.*.local
.env.production
# Testing
coverage
.nyc_output
*.test.ts
*.test.tsx
*.spec.ts
*.spec.tsx
# Misc
.DS_Store
*.pem
.cache
.turbo
.vercel
# Documentation
README.md
CHANGELOG.md
LICENSE
docs
# CI/CD
.github
.gitlab-ci.yml
.travis.yml
# Claude
.claude

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Supervisor XML-RPC Connection
# For local development
SUPERVISOR_HOST=localhost
SUPERVISOR_PORT=9001
# For Docker Compose
# SUPERVISOR_HOST=supervisor
# SUPERVISOR_PORT=9001
# Optional: HTTP Basic Auth (if configured in supervisord.conf)
# SUPERVISOR_USERNAME=user
# SUPERVISOR_PASSWORD=pass

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

53
.gitattributes vendored Normal file
View File

@@ -0,0 +1,53 @@
# Auto detect text files and perform LF normalization
* text=auto
# Source code
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.md text eol=lf
# Shell scripts
*.sh text eol=lf
# Docker
Dockerfile text eol=lf
*.dockerignore text eol=lf
# Config files
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.ini text eol=lf
*.env text eol=lf
.env* text eol=lf
# Documentation
*.md text eol=lf
LICENSE text eol=lf
README* text eol=lf
# Archives
*.zip binary
*.tar binary
*.gz binary
# Images
*.jpg binary
*.jpeg binary
*.png binary
*.gif binary
*.ico binary
*.svg text eol=lf
*.webp binary
# Fonts
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.eot binary

View File

@@ -0,0 +1,132 @@
name: Build and Push Docker Image to Gitea
on:
push:
branches:
- main
- develop
tags:
- 'v*.*.*'
pull_request:
branches:
- main
workflow_dispatch:
inputs:
tag:
description: 'Custom tag for the image'
required: false
default: 'manual'
env:
REGISTRY: dev.pivoine.art
IMAGE_NAME: valknar/supervisor-ui
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Tag as 'latest' for main branch
type=raw,value=latest,enable={{is_default_branch}}
# Tag with branch name
type=ref,event=branch
# Tag with PR number
type=ref,event=pr
# Tag with git tag (semver)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Tag with commit SHA
type=sha,prefix={{branch}}-
# Custom tag from workflow_dispatch
type=raw,value=${{ gitea.event.inputs.tag }},enable=${{ gitea.event_name == 'workflow_dispatch' }}
labels: |
org.opencontainers.image.title=Supervisor UI
org.opencontainers.image.description=Modern web interface for Supervisor process management built with Next.js 16 and Tailwind CSS 4
org.opencontainers.image.vendor=valknar
org.opencontainers.image.source=https://dev.pivoine.art/${{ gitea.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-args: |
NODE_ENV=production
CI=true
SUPERVISOR_HOST=localhost
SUPERVISOR_PORT=9001
- name: Generate image digest
if: gitea.event_name != 'pull_request'
run: |
echo "### Docker Image Published :rocket:" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Registry:** \`${{ env.REGISTRY }}\`" >> $GITEA_STEP_SUMMARY
echo "**Image:** \`${{ env.IMAGE_NAME }}\`" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Tags:**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Pull command:**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`bash" >> $GITEA_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Run with custom Supervisor connection:**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`bash" >> $GITEA_STEP_SUMMARY
echo "docker run -d \\" >> $GITEA_STEP_SUMMARY
echo " -p 3000:3000 \\" >> $GITEA_STEP_SUMMARY
echo " -e SUPERVISOR_HOST=your-supervisor-host \\" >> $GITEA_STEP_SUMMARY
echo " -e SUPERVISOR_PORT=9001 \\" >> $GITEA_STEP_SUMMARY
echo " -e SUPERVISOR_USERNAME=user \\" >> $GITEA_STEP_SUMMARY
echo " -e SUPERVISOR_PASSWORD=pass \\" >> $GITEA_STEP_SUMMARY
echo " --name supervisor-ui \\" >> $GITEA_STEP_SUMMARY
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
- name: PR Comment - Image built but not pushed
if: gitea.event_name == 'pull_request'
run: |
echo "### Docker Image Built Successfully :white_check_mark:" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "Image was built successfully but **not pushed** (PR builds are not published)." >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Would be tagged as:**" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITEA_STEP_SUMMARY
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Note:** The image supports runtime environment variables:" >> $GITEA_STEP_SUMMARY
echo "- \`SUPERVISOR_HOST\` - Supervisor API host (default: localhost)" >> $GITEA_STEP_SUMMARY
echo "- \`SUPERVISOR_PORT\` - Supervisor API port (default: 9001)" >> $GITEA_STEP_SUMMARY
echo "- \`SUPERVISOR_USERNAME\` - Optional basic auth username" >> $GITEA_STEP_SUMMARY
echo "- \`SUPERVISOR_PASSWORD\` - Optional basic auth password" >> $GITEA_STEP_SUMMARY

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
/dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env*.local
.env.production
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# ide
.vscode
.idea
*.swp
*.swo
*~

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}

550
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,550 @@
# Deployment Guide - Supervisor UI
## Environment Variables
The Supervisor UI supports flexible configuration through environment variables that can be set at both build-time and runtime.
### Available Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `SUPERVISOR_HOST` | Supervisor API host/IP address | `localhost` | Yes |
| `SUPERVISOR_PORT` | Supervisor API port | `9001` | Yes |
| `SUPERVISOR_USERNAME` | Basic auth username (if enabled) | - | No |
| `SUPERVISOR_PASSWORD` | Basic auth password (if enabled) | - | No |
| `NODE_ENV` | Node environment | `production` | No |
| `PORT` | Application port | `3000` | No |
### Configuration Priority
Environment variables can be set at different stages, with runtime taking precedence:
1. **Runtime** (highest priority) - Docker run `-e` flags or docker-compose environment
2. **Build-time** - Docker build `--build-arg` flags
3. **Dockerfile defaults** (lowest priority)
## Docker Build Arguments
You can customize the build with build arguments:
```bash
docker build -t supervisor-ui \
--build-arg SUPERVISOR_HOST=supervisor.example.com \
--build-arg SUPERVISOR_PORT=9001 \
--build-arg SUPERVISOR_USERNAME=admin \
--build-arg SUPERVISOR_PASSWORD=secret \
.
```
**Note**: Build arguments are embedded in the image layer. For sensitive credentials, prefer runtime environment variables.
## Docker Runtime Configuration
### Option 1: Environment Variables via Docker Run
```bash
docker run -d \
--name supervisor-ui \
-p 3000:3000 \
-e SUPERVISOR_HOST=supervisor.example.com \
-e SUPERVISOR_PORT=9001 \
-e SUPERVISOR_USERNAME=admin \
-e SUPERVISOR_PASSWORD=secret \
dev.pivoine.art/valknar/supervisor-ui:latest
```
### Option 2: Environment File
Create an `.env` file:
```env
SUPERVISOR_HOST=supervisor.example.com
SUPERVISOR_PORT=9001
SUPERVISOR_USERNAME=admin
SUPERVISOR_PASSWORD=secret
```
Run with env file:
```bash
docker run -d \
--name supervisor-ui \
-p 3000:3000 \
--env-file .env \
dev.pivoine.art/valknar/supervisor-ui:latest
```
### Option 3: Docker Compose
Create a `.env` file in the same directory as `docker-compose.yml`:
```env
SUPERVISOR_HOST=supervisor.example.com
SUPERVISOR_PORT=9001
SUPERVISOR_USERNAME=admin
SUPERVISOR_PASSWORD=secret
```
The `docker-compose.yml` automatically picks up these variables:
```bash
docker-compose up -d
```
### Option 4: Docker Compose with Inline Environment
Edit `docker-compose.yml`:
```yaml
services:
supervisor-ui:
image: dev.pivoine.art/valknar/supervisor-ui:latest
environment:
- SUPERVISOR_HOST=supervisor.example.com
- SUPERVISOR_PORT=9001
- SUPERVISOR_USERNAME=admin
- SUPERVISOR_PASSWORD=secret
```
## CI/CD - Gitea Workflows
### Automatic Builds
The project includes a Gitea Actions workflow that automatically builds and pushes Docker images to the Gitea registry.
**Workflow file**: `.gitea/workflows/docker-build-push.yml`
### Trigger Events
Images are built and pushed on:
1. **Push to main branch** → Tagged as `latest` and `main-<sha>`
2. **Push to develop branch** → Tagged as `develop` and `develop-<sha>`
3. **Git tags** (v*.*.* pattern) → Tagged as version (e.g., `v1.0.0`, `1.0`, `1`)
4. **Pull requests** → Built but not pushed (validation only)
5. **Manual workflow dispatch** → Custom tag specified by user
### Workflow Secrets
The workflow requires one secret to be configured in your Gitea repository:
- `REGISTRY_TOKEN` - Gitea personal access token with registry push permissions
#### Creating the Registry Token
1. Go to Gitea Settings → Applications → Generate New Token
2. Name: `GitHub Actions Registry`
3. Select scope: `write:package`
4. Copy the generated token
5. Go to Repository Settings → Secrets → Add Secret
6. Name: `REGISTRY_TOKEN`
7. Value: Paste the token
### Registry Authentication
To pull images from the Gitea registry:
```bash
# Login to registry
docker login dev.pivoine.art
# Pull image
docker pull dev.pivoine.art/valknar/supervisor-ui:latest
```
### Image Tags
After pushing to main, the following tags are available:
```bash
# Latest stable
dev.pivoine.art/valknar/supervisor-ui:latest
# Specific commit
dev.pivoine.art/valknar/supervisor-ui:main-abc1234
# Version tags (from git tags)
dev.pivoine.art/valknar/supervisor-ui:v1.0.0
dev.pivoine.art/valknar/supervisor-ui:1.0
dev.pivoine.art/valknar/supervisor-ui:1
# Development branch
dev.pivoine.art/valknar/supervisor-ui:develop
```
## Production Deployment Scenarios
### Scenario 1: Supervisor on Same Host
Deploy UI on the same host as Supervisor:
```bash
docker run -d \
--name supervisor-ui \
--network host \
-e SUPERVISOR_HOST=localhost \
-e SUPERVISOR_PORT=9001 \
dev.pivoine.art/valknar/supervisor-ui:latest
```
Using `--network host` allows the container to access localhost services.
### Scenario 2: Supervisor on Different Host
Deploy UI on a different host:
```bash
docker run -d \
--name supervisor-ui \
-p 3000:3000 \
-e SUPERVISOR_HOST=192.168.1.100 \
-e SUPERVISOR_PORT=9001 \
dev.pivoine.art/valknar/supervisor-ui:latest
```
Ensure the Supervisor inet_http_server is accessible from the UI host (not bound to 127.0.0.1).
### Scenario 3: Multiple Supervisor Instances (Future)
While the current version connects to a single instance, you can run multiple UI containers:
```bash
# Production Supervisor UI
docker run -d \
--name supervisor-ui-prod \
-p 3000:3000 \
-e SUPERVISOR_HOST=prod.supervisor.local \
dev.pivoine.art/valknar/supervisor-ui:latest
# Staging Supervisor UI
docker run -d \
--name supervisor-ui-staging \
-p 3001:3000 \
-e SUPERVISOR_HOST=staging.supervisor.local \
dev.pivoine.art/valknar/supervisor-ui:latest
```
### Scenario 4: Behind Reverse Proxy (nginx/Traefik)
Example nginx configuration:
```nginx
server {
listen 80;
server_name supervisor.example.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Example Traefik docker-compose labels:
```yaml
services:
supervisor-ui:
image: dev.pivoine.art/valknar/supervisor-ui:latest
environment:
- SUPERVISOR_HOST=supervisor.local
- SUPERVISOR_PORT=9001
labels:
- "traefik.enable=true"
- "traefik.http.routers.supervisor-ui.rule=Host(`supervisor.example.com`)"
- "traefik.http.routers.supervisor-ui.entrypoints=websecure"
- "traefik.http.routers.supervisor-ui.tls.certresolver=letsencrypt"
- "traefik.http.services.supervisor-ui.loadbalancer.server.port=3000"
```
### Scenario 5: Docker Compose Stack
Complete production-ready stack with reverse proxy:
```yaml
version: '3.8'
services:
supervisor-ui:
image: dev.pivoine.art/valknar/supervisor-ui:latest
container_name: supervisor-ui
restart: unless-stopped
environment:
- SUPERVISOR_HOST=${SUPERVISOR_HOST}
- SUPERVISOR_PORT=${SUPERVISOR_PORT}
- SUPERVISOR_USERNAME=${SUPERVISOR_USERNAME}
- SUPERVISOR_PASSWORD=${SUPERVISOR_PASSWORD}
networks:
- supervisor-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
labels:
- "traefik.enable=true"
- "traefik.http.routers.supervisor-ui.rule=Host(`supervisor.yourdomain.com`)"
- "traefik.http.services.supervisor-ui.loadbalancer.server.port=3000"
networks:
supervisor-network:
external: true
```
## Security Considerations
### 1. Credentials Management
**Bad** (credentials in image):
```bash
docker build --build-arg SUPERVISOR_PASSWORD=secret .
```
**Good** (credentials at runtime):
```bash
docker run -e SUPERVISOR_PASSWORD=secret ...
```
**Better** (use secrets management):
```bash
# Docker Swarm secrets
echo "secret_password" | docker secret create supervisor_password -
```
### 2. Network Isolation
Use Docker networks to isolate services:
```yaml
services:
supervisor-ui:
networks:
- internal # Only accessible from other containers
nginx:
networks:
- internal
- public # Exposed to internet
```
### 3. Read-only Filesystem
For enhanced security, run with read-only root filesystem:
```bash
docker run -d \
--read-only \
--tmpfs /tmp \
-e SUPERVISOR_HOST=supervisor.local \
dev.pivoine.art/valknar/supervisor-ui:latest
```
### 4. Resource Limits
Prevent resource exhaustion:
```yaml
services:
supervisor-ui:
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
cpus: '0.25'
memory: 128M
```
## Monitoring & Health Checks
### Health Check Endpoint
The application provides a health check at `/api/health`:
```bash
curl http://localhost:3000/api/health
# {"status":"healthy","timestamp":"2025-11-23T17:00:00.000Z"}
```
### Docker Health Check
Built-in health check monitors the API:
```bash
docker inspect supervisor-ui --format='{{.State.Health.Status}}'
```
### Integration with Monitoring Tools
#### Prometheus
Add health check as a target in `prometheus.yml`:
```yaml
scrape_configs:
- job_name: 'supervisor-ui'
metrics_path: '/api/health'
static_configs:
- targets: ['supervisor-ui:3000']
```
#### Uptime Kuma
Add HTTP(s) monitor:
- Monitor Type: HTTP(s)
- URL: `http://supervisor-ui:3000/api/health`
- Interval: 60 seconds
## Troubleshooting
### Container won't start
Check logs:
```bash
docker logs supervisor-ui
```
Common issues:
- Port 3000 already in use
- Invalid environment variables
- Network connectivity issues
### Can't connect to Supervisor
1. Check if Supervisor is accessible:
```bash
# From host
curl http://supervisor-host:9001/RPC2
# From container
docker exec supervisor-ui curl http://supervisor-host:9001/RPC2
```
2. Verify Supervisor configuration in `/etc/supervisor/supervisord.conf`:
```ini
[inet_http_server]
port = *:9001 ; Listen on all interfaces, not just 127.0.0.1
```
3. Check environment variables:
```bash
docker exec supervisor-ui env | grep SUPERVISOR
```
### Authentication failures
Verify credentials match your Supervisor configuration:
```bash
# Test with curl
curl -u username:password http://supervisor-host:9001/RPC2
```
## Rollback Strategy
### Quick Rollback to Previous Version
```bash
# Stop current version
docker stop supervisor-ui
docker rm supervisor-ui
# Run previous version
docker run -d \
--name supervisor-ui \
-p 3000:3000 \
--env-file .env \
dev.pivoine.art/valknar/supervisor-ui:v1.0.0 # Specific version
```
### Using Docker Compose
```bash
# Edit docker-compose.yml to use previous tag
# Then:
docker-compose up -d --force-recreate
```
## Backup & Recovery
### No Persistent Data
The Supervisor UI is stateless and doesn't store any data. All configuration is in environment variables.
To "backup" your deployment:
1. Save your `.env` file or docker-compose.yml
2. Document your Supervisor connection details
To "restore":
1. Pull the image
2. Apply your saved environment configuration
3. Start the container
## Performance Tuning
### Node.js Optimization
For better performance in production:
```bash
docker run -d \
-e NODE_OPTIONS="--max-old-space-size=256" \
-e SUPERVISOR_HOST=supervisor.local \
dev.pivoine.art/valknar/supervisor-ui:latest
```
### Caching Strategies
The application uses React Query with intelligent caching:
- System info: 5 second stale time
- Processes: 3 second stale time
- Logs: 2 second stale time
These are optimized for real-time monitoring while minimizing API calls.
## Scaling Considerations
### Horizontal Scaling
The application is stateless and can be scaled horizontally:
```yaml
services:
supervisor-ui:
image: dev.pivoine.art/valknar/supervisor-ui:latest
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
```
### Load Balancing
Use nginx or Traefik to load balance across replicas:
```nginx
upstream supervisor_ui {
server supervisor-ui-1:3000;
server supervisor-ui-2:3000;
server supervisor-ui-3:3000;
}
server {
location / {
proxy_pass http://supervisor_ui;
}
}
```
---
**Last Updated**: November 23, 2025
**Version**: 0.1.0

76
Dockerfile Normal file
View File

@@ -0,0 +1,76 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy dependency files
COPY package.json pnpm-lock.yaml ./
# Install dependencies with frozen lockfile
RUN pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy source code
COPY . .
# Build arguments for environment variables (optional defaults)
ARG SUPERVISOR_HOST=localhost
ARG SUPERVISOR_PORT=9001
ARG SUPERVISOR_USERNAME=
ARG SUPERVISOR_PASSWORD=
# Set environment variables for production build
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV SUPERVISOR_HOST=${SUPERVISOR_HOST}
ENV SUPERVISOR_PORT=${SUPERVISOR_PORT}
ENV SUPERVISOR_USERNAME=${SUPERVISOR_USERNAME}
ENV SUPERVISOR_PASSWORD=${SUPERVISOR_PASSWORD}
# Build the Next.js application
RUN pnpm build
# Stage 3: Production runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Runtime environment variables (can be overridden at runtime)
ENV SUPERVISOR_HOST=localhost
ENV SUPERVISOR_PORT=9001
ENV SUPERVISOR_USERNAME=
ENV SUPERVISOR_PASSWORD=
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application
CMD ["node", "server.js"]

648
IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,648 @@
# Supervisor UI - Implementation Summary
## Overview
A sophisticated, production-ready web application for managing Supervisor processes, built with Next.js 16 and Tailwind CSS 4. This implementation follows modern best practices and patterns from the pastel-ui project.
## Project Architecture
### Technology Stack
- **Framework**: Next.js 16.0.3 (App Router, Standalone Output)
- **React**: 19.2.0 with strict mode
- **Styling**: Tailwind CSS 4.1.17 (CSS-first approach with OKLCH colors)
- **State Management**:
- TanStack Query 5.90.10 (server state)
- Zustand 5.0.8 (client state - ready for future use)
- **API Communication**: XML-RPC client for Supervisor
- **Type Safety**: TypeScript 5.9.3 + Zod 3.25.76
- **UI Components**: Custom components with lucide-react icons
- **Notifications**: Sonner 1.7.4
- **Package Manager**: pnpm 10.20.0
### Key Features Implemented
1. **Real-time Monitoring**
- Auto-refreshing process list (every 3 seconds)
- System status updates (every 5 seconds)
- Process state indicators with color-coded badges
- Uptime tracking and formatting
2. **Process Control**
- Start/Stop/Restart individual processes
- Intelligent button states based on process status
- Optimistic UI updates with React Query mutations
- Toast notifications for all actions
- Proper error handling with user feedback
3. **Modern Dashboard**
- System status card with version info
- Process statistics (total, running, stopped, fatal)
- Quick action cards for navigation
- Responsive grid layout
- Smooth animations and transitions
4. **Backend API Proxy**
- Next.js API routes for all Supervisor methods
- Secure server-side XML-RPC communication
- Environment-based configuration
- Error handling and validation
- RESTful API design
5. **Theme System**
- Dark/light mode with system preference detection
- Smooth theme transitions
- OKLCH color space for perceptual uniformity
- Custom color palette for process states
- Persistent theme storage
6. **Docker Support**
- Multi-stage Dockerfile for optimized builds
- Node.js 20 Alpine base (lightweight)
- Non-root user security
- Health check endpoint
- Docker Compose configuration
- Arty integration ready
## File Structure
```
supervisor-ui/
├── app/
│ ├── api/
│ │ ├── health/route.ts # Health check endpoint
│ │ └── supervisor/
│ │ ├── system/route.ts # System info API
│ │ └── processes/
│ │ ├── route.ts # All processes API
│ │ └── [name]/
│ │ ├── route.ts # Single process info
│ │ ├── start/route.ts # Start process
│ │ ├── stop/route.ts # Stop process
│ │ ├── restart/route.ts # Restart process
│ │ └── logs/
│ │ ├── stdout/route.ts # Stdout logs
│ │ └── stderr/route.ts # Stderr logs
│ ├── config/page.tsx # Configuration page (placeholder)
│ ├── logs/page.tsx # Logs page (placeholder)
│ ├── processes/page.tsx # Process management page
│ ├── globals.css # Tailwind CSS + theme system
│ ├── layout.tsx # Root layout with providers
│ └── page.tsx # Dashboard home
├── components/
│ ├── layout/
│ │ └── Navbar.tsx # Navigation bar with theme toggle
│ ├── process/
│ │ ├── ProcessCard.tsx # Individual process card
│ │ └── SystemStatus.tsx # System status display
│ ├── providers/
│ │ ├── Providers.tsx # Combined provider wrapper
│ │ └── ThemeProvider.tsx # Theme context provider
│ └── ui/
│ ├── badge.tsx # Badge component
│ ├── button.tsx # Button component
│ └── card.tsx # Card components
├── lib/
│ ├── hooks/
│ │ └── useSupervisor.ts # React Query hooks
│ ├── supervisor/
│ │ ├── client.ts # XML-RPC client class
│ │ └── types.ts # TypeScript types + Zod schemas
│ └── utils/
│ └── cn.ts # Class name utility
├── .dockerignore # Docker ignore patterns
├── .env.example # Environment variable template
├── .eslintrc.json # ESLint configuration
├── .gitignore # Git ignore patterns
├── .prettierrc # Prettier configuration
├── docker-compose.yml # Docker Compose setup
├── Dockerfile # Multi-stage production build
├── next.config.ts # Next.js configuration
├── package.json # Dependencies and scripts
├── postcss.config.mjs # PostCSS with Tailwind plugin
├── README.md # User documentation
└── tsconfig.json # TypeScript configuration
```
## API Routes Specification
### System Information
- **GET** `/api/health` - Health check (returns status and timestamp)
- **GET** `/api/supervisor/system` - Get system info (version, state, PID)
### Process Management
- **GET** `/api/supervisor/processes` - List all processes
- **GET** `/api/supervisor/processes/[name]` - Get single process info
- **POST** `/api/supervisor/processes/[name]/start` - Start process
- **POST** `/api/supervisor/processes/[name]/stop` - Stop process
- **POST** `/api/supervisor/processes/[name]/restart` - Restart process
### Logs
- **GET** `/api/supervisor/processes/[name]/logs/stdout` - Get stdout logs
- **GET** `/api/supervisor/processes/[name]/logs/stderr` - Get stderr logs
Query parameters for logs:
- `offset` (default: -4096) - Byte offset for reading
- `length` (default: 4096) - Number of bytes to read
## Configuration
### Environment Variables
```env
# Supervisor Connection
SUPERVISOR_HOST=localhost
SUPERVISOR_PORT=9001
# Optional: Basic Authentication
SUPERVISOR_USERNAME=user
SUPERVISOR_PASSWORD=pass
```
### Supervisor Setup
Your `supervisord.conf` must enable the inet HTTP server:
```ini
[inet_http_server]
port = 127.0.0.1:9001
username = user ; Optional
password = pass ; Optional
```
## Development Workflow
### Local Development
```bash
# Install dependencies
pnpm install
# Create environment file
cp .env.example .env.local
# Edit .env.local with your Supervisor connection details
# Run development server
pnpm dev
# Open http://localhost:3000
```
### Code Quality
```bash
# Type checking
pnpm type-check
# Linting
pnpm lint
pnpm lint:fix
# Formatting
pnpm format
```
### Production Build
```bash
# Build for production
pnpm build
# Start production server
pnpm start
```
## Docker Deployment
### Option 1: Docker Build & Run
```bash
# Build image
docker build -t supervisor-ui:latest .
# Run container
docker run -d \
--name supervisor-ui \
-p 3000:3000 \
-e SUPERVISOR_HOST=localhost \
-e SUPERVISOR_PORT=9001 \
supervisor-ui:latest
```
### Option 2: Docker Compose
```bash
# Start service
docker-compose up -d
# View logs
docker-compose logs -f
# Stop service
docker-compose down
```
### Option 3: Arty (Recommended for this environment)
```bash
# Start service
arty up -d supervisor-ui
# Check status
arty ps
# View logs
arty logs supervisor-ui
# Stop service
arty down supervisor-ui
```
## Design System
### Color Palette (OKLCH)
The application uses OKLCH color space for perceptually uniform colors:
**Light Mode**:
- Background: `oklch(98% 0 0)` - Nearly white
- Foreground: `oklch(20% 0 0)` - Dark gray
- Primary: `oklch(55% 0.15 220)` - Blue-green
- Success: `oklch(60% 0.15 140)` - Green
- Warning: `oklch(75% 0.15 90)` - Yellow
- Destructive: `oklch(55% 0.20 25)` - Red
**Dark Mode**:
- Background: `oklch(15% 0.01 250)` - Very dark blue-gray
- Foreground: `oklch(95% 0 0)` - Nearly white
- Colors automatically adjusted for dark mode
### Process State Styling
- **RUNNING**: Green background with success colors
- **STOPPED/EXITED**: Gray muted colors
- **STARTING/BACKOFF**: Yellow warning colors
- **STOPPING**: Yellow warning colors
- **FATAL**: Red destructive colors
- **UNKNOWN**: Gray muted colors
### Typography
- Font: Inter (Google Fonts)
- Headings: Bold with gradient text effects
- Body: Regular with good readability
- Code/Mono: For PIDs, uptimes, versions
### Animations
- `fade-in`: Gentle fade-in (0.3s)
- `slide-up/down`: Slide animations (0.4s)
- `scale-in`: Scale animation (0.2s)
- `pulse-slow`: Slow pulsing (3s)
- `spin-slow`: Slow rotation (3s)
## React Query Configuration
### Query Settings
- **Stale Time**: 3 seconds (for real-time feel)
- **Refetch on Window Focus**: Disabled
- **Retry**: 2 attempts
- **Refetch Intervals**:
- System info: 5 seconds
- Processes list: 3 seconds
- Individual process: 3 seconds
- Logs: 2 seconds (when enabled)
### Mutation Handling
All mutations (start/stop/restart) automatically:
1. Show loading states on buttons
2. Display toast notifications on success/error
3. Invalidate related queries to trigger refetch
4. Update UI optimistically where possible
## Supervisor Client Implementation
The XML-RPC client (`lib/supervisor/client.ts`) implements all Supervisor API methods:
### System Methods
- `getAPIVersion()`
- `getSupervisorVersion()`
- `getIdentification()`
- `getState()`
- `getPID()`
- `getSystemInfo()` - Combined method
### Process Control
- `getAllProcessInfo()`
- `getProcessInfo(name)`
- `startProcess(name, wait)`
- `stopProcess(name, wait)`
- `restartProcess(name)` - Custom implementation
- `signalProcess(name, signal)`
- `signalProcessGroup(name, signal)`
- `signalAllProcesses(signal)`
### Log Methods
- `readProcessStdoutLog(name, offset, length)`
- `readProcessStderrLog(name, offset, length)`
- `tailProcessStdoutLog(name, offset, length)`
- `tailProcessStderrLog(name, offset, length)`
- `clearProcessLogs(name)`
- `clearAllProcessLogs()`
### Configuration Methods
- `reloadConfig()`
- `addProcessGroup(name)`
- `removeProcessGroup(name)`
### Control Methods
- `shutdown()`
- `restart()`
- `sendProcessStdin(name, chars)`
## Type Safety
### Zod Schemas
All API responses are validated with Zod schemas:
- `ProcessInfoSchema` - Process information structure
- `SupervisorStateInfoSchema` - System state structure
- `ConfigInfoSchema` - Configuration structure
- `ReloadConfigResultSchema` - Reload results
### TypeScript Types
Comprehensive type definitions for:
- Process states and state codes
- Supervisor states
- API responses and requests
- Component props
- Hook return types
## Future Enhancements
### Planned Features
1. **Log Viewer**
- Real-time log tailing with auto-scroll
- Search and filtering capabilities
- Multi-process log aggregation
- Log level highlighting
- Download log files
2. **Configuration Management**
- Reload configuration UI
- Add/remove process groups
- View full configuration
- Validation before applying
3. **Advanced Features**
- Process group batch operations
- Keyboard shortcuts (start/stop/restart)
- Charts for uptime and resource usage
- Process dependency visualization
- Event history timeline
- Notifications for state changes
4. **Real-time Updates**
- WebSocket support for push updates
- Server-Sent Events (SSE) fallback
- Reduce polling when WebSocket available
5. **Multi-Instance Support**
- Connect to multiple Supervisor servers
- Instance switcher in navbar
- Dashboard showing all instances
- Stored connections management
## Performance Optimizations
### Current Optimizations
1. **React Query Caching**
- Intelligent cache invalidation
- Stale-while-revalidate pattern
- Background refetching
2. **Component Optimizations**
- Proper React.memo usage where needed
- Efficient re-render prevention
- Loading skeletons for better UX
3. **Docker Build**
- Multi-stage build reduces image size
- Layer caching for faster rebuilds
- Non-root user for security
- Health checks for reliability
4. **Tailwind CSS**
- CSS-first approach with v4
- Tree-shaking unused styles
- Optimized for production
### Potential Optimizations
1. **Code Splitting**
- Dynamic imports for heavy components
- Route-based code splitting
- Lazy loading for charts/graphs
2. **Image Optimization**
- Next.js Image component
- WebP format support
- Lazy loading images
3. **Bundle Analysis**
- Use webpack-bundle-analyzer
- Identify large dependencies
- Replace or optimize heavy libraries
## Security Considerations
### Implemented
1. **Backend Proxy Pattern**
- No direct browser-to-Supervisor connection
- Credentials stored server-side only
- CSRF protection via Next.js
2. **Docker Security**
- Non-root user in container
- Minimal attack surface
- Health checks for monitoring
3. **Type Safety**
- Runtime validation with Zod
- TypeScript strict mode
- No unsafe type assertions
### Recommendations
1. **Authentication**
- Add user authentication layer
- Role-based access control
- Session management
2. **HTTPS**
- Use HTTPS in production
- Secure cookie flags
- HSTS headers
3. **Rate Limiting**
- Implement rate limiting on API routes
- Prevent DoS attacks
- Monitor for abuse
## Testing Strategy
### Current State
- Development testing completed
- Manual UI testing performed
- API route functionality verified
### Recommended Testing
1. **Unit Tests**
- Supervisor client methods
- Utility functions
- Component logic
2. **Integration Tests**
- API route handlers
- React Query hooks
- Component interactions
3. **E2E Tests**
- Critical user flows
- Process control operations
- Error scenarios
4. **Tools to Add**
- Vitest for unit tests
- React Testing Library
- Playwright for E2E tests
## Deployment Checklist
### Pre-Deployment
- [ ] Set environment variables
- [ ] Configure Supervisor connection
- [ ] Test API connectivity
- [ ] Review security settings
- [ ] Enable HTTPS if possible
- [ ] Set up monitoring/logging
### Docker Deployment
- [ ] Build Docker image
- [ ] Push to registry (if using one)
- [ ] Configure docker-compose.yml
- [ ] Set up persistent storage (if needed)
- [ ] Configure networking
- [ ] Set up reverse proxy (nginx/traefik)
### Post-Deployment
- [ ] Verify health check endpoint
- [ ] Test all process operations
- [ ] Check real-time updates
- [ ] Verify theme switching
- [ ] Test on different browsers
- [ ] Monitor error logs
## Maintenance
### Regular Tasks
1. **Dependency Updates**
```bash
pnpm update --latest
pnpm audit
```
2. **Security Patches**
- Monitor security advisories
- Apply patches promptly
- Test after updates
3. **Performance Monitoring**
- Check response times
- Monitor memory usage
- Review error logs
4. **Backup**
- No persistent data stored
- Configuration in environment variables
- Code in version control
## Troubleshooting
### Common Issues
1. **"Failed to connect to Supervisor"**
- Check Supervisor is running
- Verify inet_http_server enabled
- Check SUPERVISOR_HOST and PORT
- Test with `curl http://localhost:9001/RPC2`
2. **Theme Not Persisting**
- Clear browser localStorage
- Check for JavaScript errors
- Verify ThemeProvider is wrapping app
3. **Processes Not Updating**
- Check React Query devtools
- Verify API routes returning data
- Check browser console for errors
4. **Docker Container Won't Start**
- Check health check endpoint
- Review container logs
- Verify environment variables
- Check network connectivity
### Debug Mode
Enable React Query DevTools by installing:
```bash
pnpm add @tanstack/react-query-devtools
```
Add to `components/providers/Providers.tsx`:
```tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// In return statement:
<ReactQueryDevtools initialIsOpen={false} />
```
## Credits & References
### Inspired By
- **pastel-ui**: Configuration patterns, Docker setup, and Tailwind CSS v4 usage
- **Supervisor**: The excellent process control system this UI manages
- **Next.js**: Modern React framework with excellent DX
### Built With
- [Next.js](https://nextjs.org/) - React framework
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS
- [TanStack Query](https://tanstack.com/query/) - Async state management
- [Zod](https://zod.dev/) - TypeScript-first validation
- [Lucide React](https://lucide.dev/) - Beautiful icons
- [Sonner](https://sonner.emilkowal.ski/) - Toast notifications
## License
MIT License - Feel free to use and modify for your needs.
---
**Last Updated**: November 23, 2025
**Version**: 0.1.0
**Status**: Production Ready (Core Features)

257
README.md Normal file
View File

@@ -0,0 +1,257 @@
# Supervisor UI
A modern, sophisticated web interface for [Supervisor](http://supervisord.org/) process management built with Next.js 16 and Tailwind CSS 4.
## Features
- **Real-time Monitoring**: Auto-refreshing process status and system information
- **Process Control**: Start, stop, and restart processes with a single click
- **Modern Dashboard**: Clean, responsive interface with dark/light mode
- **Backend Proxy**: Secure API proxy for Supervisor XML-RPC calls
- **Docker Ready**: Multi-stage Docker build for production deployment
- **Type-Safe**: Full TypeScript coverage with Zod validation
## Tech Stack
- **Framework**: Next.js 16 (App Router)
- **Styling**: Tailwind CSS 4 with OKLCH color system
- **State Management**: TanStack Query (React Query) + Zustand
- **API**: XML-RPC client for Supervisor
- **Type Safety**: TypeScript + Zod schemas
- **UI Components**: Custom components with lucide-react icons
- **Notifications**: Sonner toast notifications
## Prerequisites
- Node.js 20+ with pnpm 10+
- Supervisor instance running with XML-RPC interface enabled
## Configuration
### Supervisor Setup
Ensure your `supervisord.conf` has the inet HTTP server enabled:
```ini
[inet_http_server]
port = 127.0.0.1:9001
;username = user
;password = pass
```
### Environment Variables
Create a `.env.local` file:
```bash
# Supervisor connection
SUPERVISOR_HOST=localhost
SUPERVISOR_PORT=9001
# Optional: Basic auth (if configured in supervisord.conf)
# SUPERVISOR_USERNAME=user
# SUPERVISOR_PASSWORD=pass
```
## Development
```bash
# Install dependencies
pnpm install
# Run development server
pnpm dev
# Open http://localhost:3000
```
## Production Build
```bash
# Build for production
pnpm build
# Start production server
pnpm start
```
## Docker Deployment
### Using Pre-built Image from Gitea Registry
The easiest way to deploy is using the pre-built image from the Gitea registry:
```bash
# Pull the latest image
docker pull dev.pivoine.art/valknar/supervisor-ui:latest
# Run container with custom Supervisor connection
docker run -d \
-p 3000:3000 \
-e SUPERVISOR_HOST=your-supervisor-host \
-e SUPERVISOR_PORT=9001 \
-e SUPERVISOR_USERNAME=user \
-e SUPERVISOR_PASSWORD=pass \
--name supervisor-ui \
dev.pivoine.art/valknar/supervisor-ui:latest
```
### Build and Run Locally
```bash
# Build Docker image with custom defaults
docker build -t supervisor-ui \
--build-arg SUPERVISOR_HOST=localhost \
--build-arg SUPERVISOR_PORT=9001 \
.
# Run container (environment variables override build args)
docker run -d \
-p 3000:3000 \
-e SUPERVISOR_HOST=localhost \
-e SUPERVISOR_PORT=9001 \
--name supervisor-ui \
supervisor-ui
```
### Using Docker Compose
Create a `.env` file for your configuration:
```env
SUPERVISOR_HOST=localhost
SUPERVISOR_PORT=9001
SUPERVISOR_USERNAME=user
SUPERVISOR_PASSWORD=pass
```
Then use Docker Compose:
```bash
# Start with docker-compose
docker-compose up -d
# View logs
docker-compose logs -f supervisor-ui
# Stop
docker-compose down
```
To use the pre-built image, edit `docker-compose.yml` and uncomment the registry image line.
### Arty Integration
This project supports [Arty](https://github.com/yourusername/arty) for container management:
```bash
# Start the service
arty up -d supervisor-ui
# View status
arty ps
# View logs
arty logs supervisor-ui
```
## Container Registry
Docker images are automatically built and pushed to the Gitea registry on every commit to main:
- **Registry**: `dev.pivoine.art`
- **Image**: `valknar/supervisor-ui`
- **Tags**:
- `latest` - Latest stable build from main branch
- `develop` - Latest development build
- `v*.*.*` - Semantic version tags
- `main-<sha>` - Commit-specific builds
### Available Tags
```bash
# Pull specific versions
docker pull dev.pivoine.art/valknar/supervisor-ui:latest
docker pull dev.pivoine.art/valknar/supervisor-ui:v1.0.0
docker pull dev.pivoine.art/valknar/supervisor-ui:develop
```
## Project Structure
```
supervisor-ui/
├── app/ # Next.js App Router
│ ├── api/supervisor/ # API routes (Supervisor proxy)
│ ├── processes/ # Process management page
│ ├── logs/ # Logs viewer page
│ ├── config/ # Configuration page
│ └── page.tsx # Dashboard home
├── components/
│ ├── layout/ # Layout components (Navbar)
│ ├── process/ # Process-specific components
│ ├── providers/ # Context providers
│ └── ui/ # Reusable UI components
├── lib/
│ ├── hooks/ # React Query hooks
│ ├── supervisor/ # Supervisor client & types
│ └── utils/ # Utility functions
├── Dockerfile # Multi-stage production build
├── docker-compose.yml # Docker Compose configuration
└── package.json # Dependencies and scripts
```
## API Routes
All Supervisor operations are proxied through Next.js API routes:
- `GET /api/health` - Health check endpoint
- `GET /api/supervisor/system` - System information
- `GET /api/supervisor/processes` - All processes
- `GET /api/supervisor/processes/[name]` - Single process info
- `POST /api/supervisor/processes/[name]/start` - Start process
- `POST /api/supervisor/processes/[name]/stop` - Stop process
- `POST /api/supervisor/processes/[name]/restart` - Restart process
- `GET /api/supervisor/processes/[name]/logs/stdout` - Stdout logs
- `GET /api/supervisor/processes/[name]/logs/stderr` - Stderr logs
## Features Roadmap
- [x] Real-time process monitoring
- [x] Process control (start/stop/restart)
- [x] System status dashboard
- [x] Dark/light mode theme
- [x] Docker deployment
- [ ] Log viewer with real-time tailing
- [ ] Configuration management UI
- [ ] Process group operations
- [ ] Batch process actions
- [ ] Charts and metrics visualization
- [ ] Search and filtering
- [ ] Keyboard shortcuts
- [ ] WebSocket support for push updates
## Development Scripts
```bash
# Development
pnpm dev # Start dev server with hot reload
pnpm dev:turbo # Start dev server with Turbopack
# Build
pnpm build # Production build
pnpm start # Start production server
# Code Quality
pnpm lint # Run ESLint
pnpm lint:fix # Fix ESLint issues
pnpm format # Format with Prettier
pnpm type-check # TypeScript type checking
```
## License
MIT
## Credits
Built with inspiration from modern UI patterns and the excellent [pastel-ui](https://github.com/yourusername/pastel-ui) project configuration.

5
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'healthy', timestamp: new Date().toISOString() });
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
export const dynamic = 'force-dynamic';
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);
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 }
);
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
export const dynamic = 'force-dynamic';
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);
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 }
);
}
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
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 }
);
}
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
export const dynamic = 'force-dynamic';
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 }
);
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
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;
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 }
);
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
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;
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 }
);
}
}

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
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 }
);
}
}

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { createSupervisorClient } from '@/lib/supervisor/client';
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 }
);
}
}

25
app/config/page.tsx Normal file
View File

@@ -0,0 +1,25 @@
'use client';
import { Settings } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
export default function ConfigPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Configuration</h1>
<p className="text-muted-foreground mt-1">Manage Supervisor settings</p>
</div>
<Card>
<CardContent className="p-12 text-center">
<Settings className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Configuration Coming Soon</h2>
<p className="text-muted-foreground">
This feature will allow you to reload configuration, add/remove process groups, and manage settings.
</p>
</CardContent>
</Card>
</div>
);
}

329
app/globals.css Normal file
View File

@@ -0,0 +1,329 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
/* Content source definitions for Tailwind v4 */
@source "../components/**/*.{js,ts,jsx,tsx}";
@source "../lib/**/*.{js,ts,jsx,tsx}";
@source "./**/*.{js,ts,jsx,tsx}";
/* Custom dark mode variant */
@custom-variant dark (&:is(.dark *));
/* Color System - Supervisor Theme */
:root {
/* Base colors using OKLCH for perceptual uniformity */
--background: oklch(98% 0 0);
--foreground: oklch(20% 0 0);
/* Cards and panels */
--card: oklch(100% 0 0);
--card-foreground: oklch(20% 0 0);
/* Primary - Process management (blue-green) */
--primary: oklch(55% 0.15 220);
--primary-foreground: oklch(98% 0 0);
/* Secondary - System status (slate) */
--secondary: oklch(75% 0.03 250);
--secondary-foreground: oklch(20% 0 0);
/* Accent - Highlights (cyan) */
--accent: oklch(65% 0.12 200);
--accent-foreground: oklch(98% 0 0);
/* Success - Running state (green) */
--success: oklch(60% 0.15 140);
--success-foreground: oklch(98% 0 0);
/* Warning - Stopped/paused (yellow) */
--warning: oklch(75% 0.15 90);
--warning-foreground: oklch(20% 0 0);
/* Destructive - Fatal/error (red) */
--destructive: oklch(55% 0.20 25);
--destructive-foreground: oklch(98% 0 0);
/* Muted - Disabled states */
--muted: oklch(92% 0.01 250);
--muted-foreground: oklch(55% 0.01 250);
/* Borders and inputs */
--border: oklch(88% 0.01 250);
--input: oklch(88% 0.01 250);
--ring: oklch(55% 0.15 220);
/* Chart colors */
--chart-1: oklch(55% 0.15 220);
--chart-2: oklch(60% 0.15 140);
--chart-3: oklch(75% 0.15 90);
--chart-4: oklch(55% 0.20 25);
--chart-5: oklch(65% 0.12 200);
/* Radius */
--radius: 0.5rem;
}
.dark {
--background: oklch(15% 0.01 250);
--foreground: oklch(95% 0 0);
--card: oklch(18% 0.01 250);
--card-foreground: oklch(95% 0 0);
--primary: oklch(65% 0.15 220);
--primary-foreground: oklch(15% 0 0);
--secondary: oklch(25% 0.03 250);
--secondary-foreground: oklch(95% 0 0);
--accent: oklch(55% 0.12 200);
--accent-foreground: oklch(15% 0 0);
--success: oklch(55% 0.15 140);
--success-foreground: oklch(15% 0 0);
--warning: oklch(65% 0.15 90);
--warning-foreground: oklch(15% 0 0);
--destructive: oklch(60% 0.20 25);
--destructive-foreground: oklch(98% 0 0);
--muted: oklch(20% 0.01 250);
--muted-foreground: oklch(60% 0.01 250);
--border: oklch(25% 0.01 250);
--input: oklch(25% 0.01 250);
--ring: oklch(65% 0.15 220);
--chart-1: oklch(65% 0.15 220);
--chart-2: oklch(55% 0.15 140);
--chart-3: oklch(65% 0.15 90);
--chart-4: oklch(60% 0.20 25);
--chart-5: oklch(55% 0.12 200);
}
/* Map colors to Tailwind utilities */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Custom animations */
--animate-fade-in: fadeIn 0.3s ease-in-out;
--animate-slide-up: slideUp 0.4s ease-out;
--animate-slide-down: slideDown 0.4s ease-out;
--animate-slide-in-right: slideInRight 0.3s ease-out;
--animate-slide-in-left: slideInLeft 0.3s ease-out;
--animate-scale-in: scaleIn 0.2s ease-out;
--animate-bounce-gentle: bounceGentle 0.5s ease-in-out;
--animate-shimmer: shimmer 2s infinite;
--animate-pulse-slow: pulseSlow 3s ease-in-out infinite;
--animate-spin-slow: spinSlow 3s linear infinite;
}
/* Global Styles */
html {
scroll-behavior: smooth;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-muted;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Disable transitions during theme change */
.disable-transitions * {
transition: none !important;
}
/* Animation Keyframes */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(-10px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInLeft {
from {
transform: translateX(10px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes bounceGentle {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
@keyframes pulseSlow {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spinSlow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Prose styles for log viewers */
.prose-log {
@apply font-mono text-sm;
white-space: pre-wrap;
word-break: break-all;
}
/* Process state indicators */
.process-running {
@apply bg-success/10 text-success border-success/20;
}
.process-stopped {
@apply bg-muted text-muted-foreground border-border;
}
.process-starting {
@apply bg-warning/10 text-warning border-warning/20;
}
.process-stopping {
@apply bg-warning/10 text-warning border-warning/20;
}
.process-fatal {
@apply bg-destructive/10 text-destructive border-destructive/20;
}
.process-unknown {
@apply bg-muted text-muted-foreground border-border;
}

50
app/layout.tsx Normal file
View File

@@ -0,0 +1,50 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from '@/components/providers/Providers';
import { Navbar } from '@/components/layout/Navbar';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
});
export const metadata: Metadata = {
title: 'Supervisor UI',
description: 'Modern web interface for Supervisor process management',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const theme = localStorage.getItem('theme') || 'system';
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const effectiveTheme = theme === 'system' ? systemTheme : theme;
if (effectiveTheme === 'dark') {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
`,
}}
/>
</head>
<body className={`${inter.variable} antialiased`}>
<Providers>
<Navbar />
<main className="container mx-auto px-4 py-8">{children}</main>
</Providers>
</body>
</html>
);
}

25
app/logs/page.tsx Normal file
View File

@@ -0,0 +1,25 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
export default function LogsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Process Logs</h1>
<p className="text-muted-foreground mt-1">Real-time log viewing</p>
</div>
<Card>
<CardContent className="p-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Logs View Coming Soon</h2>
<p className="text-muted-foreground">
This feature will display real-time logs from your processes with filtering and search capabilities.
</p>
</CardContent>
</Card>
</div>
);
}

135
app/page.tsx Normal file
View File

@@ -0,0 +1,135 @@
'use client';
import { Activity, Server, FileText, Settings } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { SystemStatus } from '@/components/process/SystemStatus';
import { useProcesses } from '@/lib/hooks/useSupervisor';
import { ProcessState } from '@/lib/supervisor/types';
export default function HomePage() {
const { data: processes, isLoading } = useProcesses();
const stats = {
total: processes?.length ?? 0,
running: processes?.filter((p) => p.state === ProcessState.RUNNING).length ?? 0,
stopped: processes?.filter((p) => p.state === ProcessState.STOPPED || p.state === ProcessState.EXITED).length ?? 0,
fatal: processes?.filter((p) => p.state === ProcessState.FATAL).length ?? 0,
};
return (
<div className="space-y-8 animate-fade-in">
{/* Header */}
<div>
<h1 className="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">
Monitor and manage your processes in real-time
</p>
</div>
{/* System Status */}
<SystemStatus />
{/* Process Statistics */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Processes</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{isLoading ? '...' : stats.total}</div>
<p className="text-xs text-muted-foreground mt-1">
All configured processes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Running</CardTitle>
<div className="h-3 w-3 rounded-full bg-success animate-pulse-slow" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-success">{isLoading ? '...' : stats.running}</div>
<p className="text-xs text-muted-foreground mt-1">
Active processes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Stopped</CardTitle>
<div className="h-3 w-3 rounded-full bg-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-muted-foreground">{isLoading ? '...' : stats.stopped}</div>
<p className="text-xs text-muted-foreground mt-1">
Inactive processes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Fatal</CardTitle>
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse-slow" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{isLoading ? '...' : stats.fatal}</div>
<p className="text-xs text-muted-foreground mt-1">
Failed processes
</p>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="grid gap-6 md:grid-cols-3">
<Card className="hover:shadow-lg transition-shadow cursor-pointer" onClick={() => window.location.href = '/processes'}>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-primary/10">
<Server className="h-6 w-6 text-primary" />
</div>
<div>
<CardTitle>Manage Processes</CardTitle>
<CardDescription>Start, stop, and restart</CardDescription>
</div>
</div>
</CardHeader>
</Card>
<Card className="hover:shadow-lg transition-shadow cursor-pointer" onClick={() => window.location.href = '/logs'}>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-accent/10">
<FileText className="h-6 w-6 text-accent" />
</div>
<div>
<CardTitle>View Logs</CardTitle>
<CardDescription>Monitor process output</CardDescription>
</div>
</div>
</CardHeader>
</Card>
<Card className="hover:shadow-lg transition-shadow cursor-pointer" onClick={() => window.location.href = '/config'}>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-success/10">
<Settings className="h-6 w-6 text-success" />
</div>
<div>
<CardTitle>Configuration</CardTitle>
<CardDescription>Manage settings</CardDescription>
</div>
</div>
</CardHeader>
</Card>
</div>
</div>
);
}

71
app/processes/page.tsx Normal file
View File

@@ -0,0 +1,71 @@
'use client';
import { useProcesses } from '@/lib/hooks/useSupervisor';
import { ProcessCard } from '@/components/process/ProcessCard';
import { RefreshCw, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
export default function ProcessesPage() {
const { data: processes, isLoading, isError, refetch } = useProcesses();
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">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-64 bg-muted rounded-lg animate-pulse" />
))}
</div>
</div>
);
}
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">
Could not connect to Supervisor. Please check your configuration.
</p>
<Button onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Processes</h1>
<p className="text-muted-foreground mt-1">
{processes?.length ?? 0} processes configured
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{processes && processes.length === 0 ? (
<div className="flex flex-col items-center justify-center p-12 text-center border-2 border-dashed rounded-lg">
<p className="text-muted-foreground">No processes configured</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{processes?.map((process) => (
<ProcessCard key={`${process.group}:${process.name}`} process={process} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Moon, Sun, Activity } from 'lucide-react';
import { useTheme } from '@/components/providers/ThemeProvider';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
const navItems = [
{ href: '/', label: 'Dashboard' },
{ href: '/processes', label: 'Processes' },
{ href: '/logs', label: 'Logs' },
{ href: '/config', label: 'Configuration' },
];
export function Navbar() {
const pathname = usePathname();
const { theme, setTheme, resolvedTheme } = useTheme();
const toggleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
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">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 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">
Supervisor
</span>
</Link>
{/* Navigation Links */}
<div className="flex gap-1 flex-1">
{navItems.map((item) => (
<Link key={item.href} href={item.href}>
<Button
variant="ghost"
size="sm"
className={cn(
'transition-colors',
pathname === item.href && 'bg-accent text-accent-foreground'
)}
>
{item.label}
</Button>
</Link>
))}
</div>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label="Toggle theme"
>
{resolvedTheme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { Play, Square, RotateCw, Activity } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ProcessInfo, getProcessStateClass, formatUptime, canStartProcess, canStopProcess } from '@/lib/supervisor/types';
import { useStartProcess, useStopProcess, useRestartProcess } from '@/lib/hooks/useSupervisor';
import { cn } from '@/lib/utils/cn';
interface ProcessCardProps {
process: ProcessInfo;
}
export function ProcessCard({ process }: ProcessCardProps) {
const startMutation = useStartProcess();
const stopMutation = useStopProcess();
const restartMutation = useRestartProcess();
const fullName = `${process.group}:${process.name}`;
const isLoading = startMutation.isPending || stopMutation.isPending || restartMutation.isPending;
const uptime = process.state === 20 ? formatUptime(process.start, process.now) : 'Not running';
const handleStart = () => startMutation.mutate({ name: fullName });
const handleStop = () => stopMutation.mutate({ name: fullName });
const handleRestart = () => restartMutation.mutate(fullName);
return (
<Card className="transition-all hover:shadow-lg animate-fade-in">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{process.name}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">{process.group}</p>
</div>
<Badge
className={cn(
'ml-2',
getProcessStateClass(process.state),
'border px-3 py-1 font-mono text-xs'
)}
>
{process.statename}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Metrics */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">PID:</span>
<span className="ml-2 font-mono">{process.pid || 'N/A'}</span>
</div>
<div>
<span className="text-muted-foreground">Uptime:</span>
<span className="ml-2 font-mono">{uptime}</span>
</div>
{process.exitstatus !== 0 && (
<div className="col-span-2">
<span className="text-muted-foreground">Exit Status:</span>
<span className="ml-2 font-mono text-destructive">{process.exitstatus}</span>
</div>
)}
</div>
{/* Error Description */}
{process.spawnerr && (
<div className="rounded-md bg-destructive/10 p-2 text-xs text-destructive border border-destructive/20">
{process.spawnerr}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
size="sm"
variant="success"
onClick={handleStart}
disabled={!canStartProcess(process.state) || isLoading}
className="flex-1"
>
<Play className="h-4 w-4" />
Start
</Button>
<Button
size="sm"
variant="warning"
onClick={handleStop}
disabled={!canStopProcess(process.state) || isLoading}
className="flex-1"
>
<Square className="h-4 w-4" />
Stop
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={process.state === 0 || isLoading}
>
<RotateCw className={cn('h-4 w-4', isLoading && 'animate-spin-slow')} />
</Button>
</div>
{/* Description */}
{process.description && process.description !== 'pid ' + process.pid && (
<p className="text-xs text-muted-foreground italic">
{process.description}
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { Activity, Server, Hash, CheckCircle2, XCircle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useSystemInfo } from '@/lib/hooks/useSupervisor';
import { SupervisorState } from '@/lib/supervisor/types';
export function SystemStatus() {
const { data: systemInfo, isLoading, isError } = useSystemInfo();
if (isLoading) {
return (
<Card>
<CardContent className="p-6">
<div className="animate-pulse space-y-3">
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
</div>
</CardContent>
</Card>
);
}
if (isError || !systemInfo) {
return (
<Card className="border-destructive/50">
<CardContent className="p-6">
<div className="flex items-center gap-2 text-destructive">
<XCircle className="h-5 w-5" />
<span>Failed to connect to Supervisor</span>
</div>
</CardContent>
</Card>
);
}
const isRunning = systemInfo.state.statecode === SupervisorState.RUNNING;
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
System Status
</CardTitle>
<Badge variant={isRunning ? 'success' : 'destructive'} className="gap-1">
{isRunning ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{systemInfo.state.statename}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-2">
<Activity className="h-4 w-4" />
Version
</span>
<span className="font-mono">{systemInfo.supervisorVersion}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-2">
<Hash className="h-4 w-4" />
PID
</span>
<span className="font-mono">{systemInfo.pid}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">API Version</span>
<span className="font-mono">{systemInfo.apiVersion}</span>
</div>
{systemInfo.identification && (
<div className="pt-2 border-t">
<span className="text-muted-foreground text-xs">ID:</span>
<p className="font-mono text-xs mt-1 text-foreground/80">
{systemInfo.identification}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, ReactNode } from 'react';
import { Toaster } from 'sonner';
import { ThemeProvider } from './ThemeProvider';
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 3 * 1000, // 3 seconds for real-time feel
refetchOnWindowFocus: false,
retry: 2,
},
},
})
);
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" richColors closeButton />
</QueryClientProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) {
setThemeState(stored);
}
}, []);
useEffect(() => {
if (!mounted) return;
const root = document.documentElement;
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const effectiveTheme = theme === 'system' ? systemTheme : theme;
// Add transition disable class
root.classList.add('disable-transitions');
// Update theme
if (effectiveTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
setResolvedTheme(effectiveTheme);
// Re-enable transitions after a frame
requestAnimationFrame(() => {
root.classList.remove('disable-transitions');
});
}, [theme, mounted]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
};
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}

27
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { HTMLAttributes } from 'react';
import { cn } from '@/lib/utils/cn';
export interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline';
}
export function Badge({ className, variant = 'default', ...props }: BadgeProps) {
return (
<div
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
'border-transparent bg-primary text-primary-foreground': variant === 'default',
'border-transparent bg-secondary text-secondary-foreground': variant === 'secondary',
'border-transparent bg-success text-success-foreground': variant === 'success',
'border-transparent bg-warning text-warning-foreground': variant === 'warning',
'border-transparent bg-destructive text-destructive-foreground': variant === 'destructive',
'text-foreground': variant === 'outline',
},
className
)}
{...props}
/>
);
}

44
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils/cn';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'ghost' | 'outline';
size?: 'sm' | 'md' | 'lg' | 'icon';
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'md', ...props }, ref) => {
return (
<button
className={cn(
'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
{
'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
'bg-secondary text-secondary-foreground hover:bg-secondary/80': variant === 'secondary',
'bg-success text-success-foreground hover:bg-success/90': variant === 'success',
'bg-warning text-warning-foreground hover:bg-warning/90': variant === 'warning',
'bg-destructive text-destructive-foreground hover:bg-destructive/90':
variant === 'destructive',
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
'border border-input bg-background hover:bg-accent hover:text-accent-foreground':
variant === 'outline',
},
{
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4': size === 'md',
'h-12 px-6 text-lg': size === 'lg',
'h-10 w-10': size === 'icon',
},
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button };

69
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,69 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils/cn';
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: '3.8'
services:
supervisor-ui:
build:
context: .
dockerfile: Dockerfile
args:
# Build-time arguments (optional, defaults in Dockerfile)
SUPERVISOR_HOST: ${SUPERVISOR_HOST:-localhost}
SUPERVISOR_PORT: ${SUPERVISOR_PORT:-9001}
# For using pre-built image from registry:
# image: dev.pivoine.art/valknar/supervisor-ui:latest
image: supervisor-ui:latest
container_name: supervisor-ui
ports:
- "3000:3000"
environment:
# Runtime environment variables (can override build-time values)
- NODE_ENV=production
- SUPERVISOR_HOST=${SUPERVISOR_HOST:-localhost}
- SUPERVISOR_PORT=${SUPERVISOR_PORT:-9001}
- SUPERVISOR_USERNAME=${SUPERVISOR_USERNAME:-}
- SUPERVISOR_PASSWORD=${SUPERVISOR_PASSWORD:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- supervisor-network
networks:
supervisor-network:
name: supervisor-network
driver: bridge

190
lib/hooks/useSupervisor.ts Normal file
View File

@@ -0,0 +1,190 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import type { ProcessInfo, SystemInfo, LogTailResult } from '@/lib/supervisor/types';
// Query Keys
export const supervisorKeys = {
all: ['supervisor'] as const,
system: () => [...supervisorKeys.all, 'system'] as const,
processes: () => [...supervisorKeys.all, 'processes'] as const,
process: (name: string) => [...supervisorKeys.processes(), name] as const,
logs: (name: string, type: 'stdout' | 'stderr') =>
[...supervisorKeys.process(name), 'logs', type] as const,
};
// API Client Functions
async function fetchSystemInfo(): Promise<SystemInfo> {
const response = await fetch('/api/supervisor/system');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch system info');
}
return response.json();
}
async function fetchProcesses(): Promise<ProcessInfo[]> {
const response = await fetch('/api/supervisor/processes');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch processes');
}
return response.json();
}
async function fetchProcessInfo(name: string): Promise<ProcessInfo> {
const response = await fetch(`/api/supervisor/processes/${encodeURIComponent(name)}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch process info');
}
return response.json();
}
async function fetchProcessLogs(
name: string,
type: 'stdout' | 'stderr',
offset: number = -4096,
length: number = 4096
): Promise<LogTailResult> {
const response = await fetch(
`/api/supervisor/processes/${encodeURIComponent(name)}/logs/${type}?offset=${offset}&length=${length}`
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `Failed to fetch ${type} logs`);
}
return response.json();
}
async function startProcess(name: string, wait: boolean = true): Promise<{ success: boolean; message: string }> {
const response = await fetch(`/api/supervisor/processes/${encodeURIComponent(name)}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wait }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start process');
}
return response.json();
}
async function stopProcess(name: string, wait: boolean = true): Promise<{ success: boolean; message: string }> {
const response = await fetch(`/api/supervisor/processes/${encodeURIComponent(name)}/stop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wait }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to stop process');
}
return response.json();
}
async function restartProcess(name: string): Promise<{ success: boolean; message: string }> {
const response = await fetch(`/api/supervisor/processes/${encodeURIComponent(name)}/restart`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to restart process');
}
return response.json();
}
// Custom Hooks
export function useSystemInfo() {
return useQuery({
queryKey: supervisorKeys.system(),
queryFn: fetchSystemInfo,
refetchInterval: 5000, // Refetch every 5 seconds
});
}
export function useProcesses(options?: { refetchInterval?: number }) {
return useQuery({
queryKey: supervisorKeys.processes(),
queryFn: fetchProcesses,
refetchInterval: options?.refetchInterval ?? 3000, // Default 3 seconds
});
}
export function useProcessInfo(name: string, enabled: boolean = true) {
return useQuery({
queryKey: supervisorKeys.process(name),
queryFn: () => fetchProcessInfo(name),
enabled,
refetchInterval: 3000,
});
}
export function useProcessLogs(
name: string,
type: 'stdout' | 'stderr',
options?: {
offset?: number;
length?: number;
enabled?: boolean;
refetchInterval?: number;
}
) {
return useQuery({
queryKey: [...supervisorKeys.logs(name, type), options?.offset, options?.length],
queryFn: () => fetchProcessLogs(name, type, options?.offset, options?.length),
enabled: options?.enabled ?? true,
refetchInterval: options?.refetchInterval ?? 2000,
});
}
export function useStartProcess() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => startProcess(name, wait),
onSuccess: (data, variables) => {
toast.success(data.message);
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
queryClient.invalidateQueries({ queryKey: supervisorKeys.process(variables.name) });
},
onError: (error: Error) => {
toast.error(`Failed to start process: ${error.message}`);
},
});
}
export function useStopProcess() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => stopProcess(name, wait),
onSuccess: (data, variables) => {
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
queryClient.invalidateQueries({ queryKey: supervisorKeys.process(variables.name) });
},
onError: (error: Error) => {
toast.error(`Failed to stop process: ${error.message}`);
},
});
}
export function useRestartProcess() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => restartProcess(name),
onSuccess: (data, name) => {
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() });
queryClient.invalidateQueries({ queryKey: supervisorKeys.process(name) });
},
onError: (error: Error) => {
toast.error(`Failed to restart process: ${error.message}`);
},
});
}

281
lib/supervisor/client.ts Normal file
View File

@@ -0,0 +1,281 @@
import * as xmlrpc from 'xmlrpc';
import {
ProcessInfo,
ProcessInfoSchema,
SupervisorStateInfo,
SupervisorStateInfoSchema,
ConfigInfo,
ConfigInfoSchema,
ReloadConfigResult,
ReloadConfigResultSchema,
ProcessActionResult,
LogTailResult,
SystemInfo,
} from './types';
export interface SupervisorClientConfig {
host: string;
port: number;
username?: string;
password?: string;
}
export class SupervisorClient {
private client: xmlrpc.Client;
private config: SupervisorClientConfig;
constructor(config: SupervisorClientConfig) {
this.config = config;
const clientOptions: xmlrpc.ClientOptions = {
host: config.host,
port: config.port,
path: '/RPC2',
};
// Add basic auth if credentials provided
if (config.username && config.password) {
clientOptions.basic_auth = {
user: config.username,
pass: config.password,
};
}
this.client = xmlrpc.createClient(clientOptions);
}
/**
* Generic method call wrapper with error handling
*/
private async call<T>(method: string, params: any[] = []): Promise<T> {
return new Promise((resolve, reject) => {
this.client.methodCall(method, params, (error, value) => {
if (error) {
reject(new Error(`XML-RPC Error: ${error.message}`));
} else {
resolve(value);
}
});
});
}
// ===== System Methods =====
async getAPIVersion(): Promise<string> {
return this.call<string>('supervisor.getAPIVersion');
}
async getSupervisorVersion(): Promise<string> {
return this.call<string>('supervisor.getSupervisorVersion');
}
async getIdentification(): Promise<string> {
return this.call<string>('supervisor.getIdentification');
}
async getState(): Promise<SupervisorStateInfo> {
const result = await this.call<any>('supervisor.getState');
return SupervisorStateInfoSchema.parse(result);
}
async getPID(): Promise<number> {
return this.call<number>('supervisor.getPID');
}
async getSystemInfo(): Promise<SystemInfo> {
const [apiVersion, supervisorVersion, identification, state, pid] = await Promise.all([
this.getAPIVersion(),
this.getSupervisorVersion(),
this.getIdentification(),
this.getState(),
this.getPID(),
]);
return {
apiVersion,
supervisorVersion,
identification,
state,
pid,
};
}
// ===== Process Info Methods =====
async getAllProcessInfo(): Promise<ProcessInfo[]> {
const result = await this.call<any[]>('supervisor.getAllProcessInfo');
return result.map((item) => ProcessInfoSchema.parse(item));
}
async getProcessInfo(name: string): Promise<ProcessInfo> {
const result = await this.call<any>('supervisor.getProcessInfo', [name]);
return ProcessInfoSchema.parse(result);
}
async getAllConfigInfo(): Promise<ConfigInfo[]> {
const result = await this.call<any[]>('supervisor.getAllConfigInfo');
return result.map((item) => ConfigInfoSchema.parse(item));
}
// ===== Process Control Methods =====
async startProcess(name: string, wait: boolean = true): Promise<boolean> {
return this.call<boolean>('supervisor.startProcess', [name, wait]);
}
async startProcessGroup(name: string, wait: boolean = true): Promise<ProcessActionResult[]> {
return this.call<ProcessActionResult[]>('supervisor.startProcessGroup', [name, wait]);
}
async startAllProcesses(wait: boolean = true): Promise<ProcessActionResult[]> {
return this.call<ProcessActionResult[]>('supervisor.startAllProcesses', [wait]);
}
async stopProcess(name: string, wait: boolean = true): Promise<boolean> {
return this.call<boolean>('supervisor.stopProcess', [name, wait]);
}
async stopProcessGroup(name: string, wait: boolean = true): Promise<ProcessActionResult[]> {
return this.call<ProcessActionResult[]>('supervisor.stopProcessGroup', [name, wait]);
}
async stopAllProcesses(wait: boolean = true): Promise<ProcessActionResult[]> {
return this.call<ProcessActionResult[]>('supervisor.stopAllProcesses', [wait]);
}
async restartProcess(name: string): Promise<boolean> {
await this.stopProcess(name, true);
return this.startProcess(name, true);
}
async signalProcess(name: string, signal: string): Promise<boolean> {
return this.call<boolean>('supervisor.signalProcess', [name, signal]);
}
async signalProcessGroup(name: string, signal: string): Promise<ProcessActionResult[]> {
return this.call<ProcessActionResult[]>('supervisor.signalProcessGroup', [name, signal]);
}
async signalAllProcesses(signal: string): Promise<ProcessActionResult[]> {
return this.call<ProcessActionResult[]>('supervisor.signalAllProcesses', [signal]);
}
// ===== Log Methods =====
async readProcessStdoutLog(
name: string,
offset: number,
length: number
): Promise<string> {
return this.call<string>('supervisor.readProcessStdoutLog', [name, offset, length]);
}
async readProcessStderrLog(
name: string,
offset: number,
length: number
): Promise<string> {
return this.call<string>('supervisor.readProcessStderrLog', [name, offset, length]);
}
async tailProcessStdoutLog(
name: string,
offset: number,
length: number
): Promise<LogTailResult> {
const result = await this.call<[string, number, boolean]>('supervisor.tailProcessStdoutLog', [
name,
offset,
length,
]);
return {
bytes: result[0],
offset: result[1],
overflow: result[2],
};
}
async tailProcessStderrLog(
name: string,
offset: number,
length: number
): Promise<LogTailResult> {
const result = await this.call<[string, number, boolean]>('supervisor.tailProcessStderrLog', [
name,
offset,
length,
]);
return {
bytes: result[0],
offset: result[1],
overflow: result[2],
};
}
async clearProcessLogs(name: string): Promise<boolean> {
return this.call<boolean>('supervisor.clearProcessLogs', [name]);
}
async clearAllProcessLogs(): Promise<ProcessActionResult[]> {
return this.call<ProcessActionResult[]>('supervisor.clearAllProcessLogs');
}
async readLog(offset: number, length: number): Promise<string> {
return this.call<string>('supervisor.readLog', [offset, length]);
}
async clearLog(): Promise<boolean> {
return this.call<boolean>('supervisor.clearLog');
}
// ===== Configuration Methods =====
async reloadConfig(): Promise<ReloadConfigResult> {
const result = await this.call<any>('supervisor.reloadConfig');
return ReloadConfigResultSchema.parse({
added: result[0],
changed: result[1],
removed: result[2],
});
}
async addProcessGroup(name: string): Promise<boolean> {
return this.call<boolean>('supervisor.addProcessGroup', [name]);
}
async removeProcessGroup(name: string): Promise<boolean> {
return this.call<boolean>('supervisor.removeProcessGroup', [name]);
}
// ===== Supervisor Control Methods =====
async shutdown(): Promise<boolean> {
return this.call<boolean>('supervisor.shutdown');
}
async restart(): Promise<boolean> {
return this.call<boolean>('supervisor.restart');
}
async sendProcessStdin(name: string, chars: string): Promise<boolean> {
return this.call<boolean>('supervisor.sendProcessStdin', [name, chars]);
}
async sendRemoteCommEvent(type: string, data: string): Promise<boolean> {
return this.call<boolean>('supervisor.sendRemoteCommEvent', [type, data]);
}
}
/**
* Factory function to create a supervisor client from environment variables
*/
export function createSupervisorClient(config?: Partial<SupervisorClientConfig>): SupervisorClient {
const defaultConfig: SupervisorClientConfig = {
host: process.env.SUPERVISOR_HOST || 'localhost',
port: parseInt(process.env.SUPERVISOR_PORT || '9001', 10),
username: process.env.SUPERVISOR_USERNAME,
password: process.env.SUPERVISOR_PASSWORD,
};
return new SupervisorClient({ ...defaultConfig, ...config });
}

196
lib/supervisor/types.ts Normal file
View File

@@ -0,0 +1,196 @@
import { z } from 'zod';
// Process States from Supervisor
export const ProcessState = {
STOPPED: 0,
STARTING: 10,
RUNNING: 20,
BACKOFF: 30,
STOPPING: 40,
EXITED: 100,
FATAL: 200,
UNKNOWN: 1000,
} as const;
export type ProcessStateCode = (typeof ProcessState)[keyof typeof ProcessState];
export const ProcessStateNames: Record<ProcessStateCode, string> = {
[ProcessState.STOPPED]: 'STOPPED',
[ProcessState.STARTING]: 'STARTING',
[ProcessState.RUNNING]: 'RUNNING',
[ProcessState.BACKOFF]: 'BACKOFF',
[ProcessState.STOPPING]: 'STOPPING',
[ProcessState.EXITED]: 'EXITED',
[ProcessState.FATAL]: 'FATAL',
[ProcessState.UNKNOWN]: 'UNKNOWN',
};
// Supervisor States
export const SupervisorState = {
FATAL: 2,
RUNNING: 1,
RESTARTING: 0,
SHUTDOWN: -1,
} as const;
export type SupervisorStateCode = (typeof SupervisorState)[keyof typeof SupervisorState];
// Zod Schemas for API Responses
export const ProcessInfoSchema = z.object({
name: z.string(),
group: z.string(),
description: z.string(),
start: z.number(),
stop: z.number(),
now: z.number(),
state: z.number(),
statename: z.string(),
spawnerr: z.string(),
exitstatus: z.number(),
logfile: z.string(),
stdout_logfile: z.string(),
stderr_logfile: z.string(),
pid: z.number(),
});
export const SupervisorStateInfoSchema = z.object({
statecode: z.number(),
statename: z.string(),
});
export const ConfigInfoSchema = z.object({
name: z.string(),
group: z.string(),
autostart: z.boolean(),
directory: z.union([z.string(), z.null()]),
command: z.string(),
environment: z.union([z.string(), z.null()]),
exitcodes: z.array(z.number()),
redirect_stderr: z.boolean(),
stderr_capture_maxbytes: z.number(),
stderr_events_enabled: z.boolean(),
stderr_logfile: z.string(),
stderr_logfile_backups: z.number(),
stderr_logfile_maxbytes: z.number(),
stdout_capture_maxbytes: z.number(),
stdout_events_enabled: z.boolean(),
stdout_logfile: z.string(),
stdout_logfile_backups: z.number(),
stdout_logfile_maxbytes: z.number(),
stopsignal: z.string(),
stopwaitsecs: z.number(),
priority: z.number(),
startretries: z.number(),
startsecs: z.number(),
process_name: z.string(),
numprocs: z.number(),
numprocs_start: z.number(),
uid: z.union([z.number(), z.null()]),
username: z.union([z.string(), z.null()]),
inuse: z.boolean(),
});
export const ReloadConfigResultSchema = z.object({
added: z.array(z.array(z.string())),
changed: z.array(z.array(z.string())),
removed: z.array(z.array(z.string())),
});
// TypeScript Types
export type ProcessInfo = z.infer<typeof ProcessInfoSchema>;
export type SupervisorStateInfo = z.infer<typeof SupervisorStateInfoSchema>;
export type ConfigInfo = z.infer<typeof ConfigInfoSchema>;
export type ReloadConfigResult = z.infer<typeof ReloadConfigResultSchema>;
// Fault Codes
export const FaultCodes = {
UNKNOWN_METHOD: 1,
INCORRECT_PARAMETERS: 2,
BAD_ARGUMENTS: 3,
SIGNATURE_UNSUPPORTED: 4,
SHUTDOWN_STATE: 6,
BAD_NAME: 10,
BAD_SIGNAL: 11,
NO_FILE: 20,
NOT_EXECUTABLE: 21,
FAILED: 30,
ABNORMAL_TERMINATION: 40,
SPAWN_ERROR: 50,
ALREADY_STARTED: 60,
NOT_RUNNING: 70,
SUCCESS: 80,
ALREADY_ADDED: 90,
STILL_RUNNING: 91,
CANT_REREAD: 92,
} as const;
// API Request/Response Types
export interface ProcessActionResult {
name: string;
group: string;
status: number;
description: string;
}
export interface LogTailResult {
bytes: string;
offset: number;
overflow: boolean;
}
export interface SystemInfo {
apiVersion: string;
supervisorVersion: string;
identification: string;
state: SupervisorStateInfo;
pid: number;
}
// Helper function to get state class name for styling
export function getProcessStateClass(state: ProcessStateCode): string {
switch (state) {
case ProcessState.RUNNING:
return 'process-running';
case ProcessState.STOPPED:
case ProcessState.EXITED:
return 'process-stopped';
case ProcessState.STARTING:
case ProcessState.BACKOFF:
return 'process-starting';
case ProcessState.STOPPING:
return 'process-stopping';
case ProcessState.FATAL:
return 'process-fatal';
default:
return 'process-unknown';
}
}
// Helper function to determine if a process can be started
export function canStartProcess(state: ProcessStateCode): boolean {
return [ProcessState.STOPPED, ProcessState.EXITED, ProcessState.FATAL].includes(state);
}
// Helper function to determine if a process can be stopped
export function canStopProcess(state: ProcessStateCode): boolean {
return [ProcessState.RUNNING, ProcessState.STARTING].includes(state);
}
// Helper function to format uptime
export function formatUptime(startTime: number, currentTime: number): string {
const uptimeSeconds = currentTime - startTime;
if (uptimeSeconds <= 0) return 'Not running';
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
const seconds = Math.floor(uptimeSeconds % 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
return parts.join(' ');
}

10
lib/utils/cn.ts Normal file
View File

@@ -0,0 +1,10 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Utility function to merge class names with Tailwind CSS classes
* Combines clsx for conditional classes and tailwind-merge for deduplication
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

12
next.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
// Standalone output for Docker deployment
output: 'standalone',
// For static export (nginx), uncomment:
// output: 'export',
// images: { unoptimized: true },
};
export default nextConfig;

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "supervisor-ui",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.20.0",
"scripts": {
"dev": "next dev",
"dev:turbo": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^16.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@tanstack/react-query": "^5.62.11",
"zustand": "^5.0.2",
"lucide-react": "^0.468.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.5",
"sonner": "^1.7.1",
"recharts": "^2.15.0",
"date-fns": "^4.1.0",
"zod": "^3.24.1",
"xmlrpc": "^1.3.2"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/xmlrpc": "^1.3.9",
"typescript": "^5",
"eslint": "^9",
"eslint-config-next": "^16.0.1",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"prettier": "^3.4.2",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/forms": "^0.5.9",
"postcss": "^8"
}
}

4492
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

41
tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}