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
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:
59
.dockerignore
Normal file
59
.dockerignore
Normal 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
12
.env.example
Normal 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
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
53
.gitattributes
vendored
Normal file
53
.gitattributes
vendored
Normal 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
|
||||
132
.gitea/workflows/docker-build-push.yml
Normal file
132
.gitea/workflows/docker-build-push.yml
Normal 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
51
.gitignore
vendored
Normal 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
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
550
DEPLOYMENT.md
Normal file
550
DEPLOYMENT.md
Normal 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
76
Dockerfile
Normal 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
648
IMPLEMENTATION.md
Normal 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
257
README.md
Normal 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
5
app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'healthy', timestamp: new Date().toISOString() });
|
||||
}
|
||||
27
app/api/supervisor/processes/[name]/logs/stderr/route.ts
Normal file
27
app/api/supervisor/processes/[name]/logs/stderr/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
app/api/supervisor/processes/[name]/logs/stdout/route.ts
Normal file
27
app/api/supervisor/processes/[name]/logs/stdout/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/api/supervisor/processes/[name]/restart/route.ts
Normal file
21
app/api/supervisor/processes/[name]/restart/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
app/api/supervisor/processes/[name]/route.ts
Normal file
23
app/api/supervisor/processes/[name]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
app/api/supervisor/processes/[name]/start/route.ts
Normal file
24
app/api/supervisor/processes/[name]/start/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
app/api/supervisor/processes/[name]/stop/route.ts
Normal file
24
app/api/supervisor/processes/[name]/stop/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
18
app/api/supervisor/processes/route.ts
Normal file
18
app/api/supervisor/processes/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
18
app/api/supervisor/system/route.ts
Normal file
18
app/api/supervisor/system/route.ts
Normal 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
25
app/config/page.tsx
Normal 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
329
app/globals.css
Normal 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
50
app/layout.tsx
Normal 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
25
app/logs/page.tsx
Normal 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
135
app/page.tsx
Normal 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
71
app/processes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
components/layout/Navbar.tsx
Normal file
70
components/layout/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
components/process/ProcessCard.tsx
Normal file
115
components/process/ProcessCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
components/process/SystemStatus.tsx
Normal file
88
components/process/SystemStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
components/providers/Providers.tsx
Normal file
30
components/providers/Providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
components/providers/ThemeProvider.tsx
Normal file
77
components/providers/ThemeProvider.tsx
Normal 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
27
components/ui/badge.tsx
Normal 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
44
components/ui/button.tsx
Normal 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
69
components/ui/card.tsx
Normal 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
38
docker-compose.yml
Normal 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
190
lib/hooks/useSupervisor.ts
Normal 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
281
lib/supervisor/client.ts
Normal 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
196
lib/supervisor/types.ts
Normal 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
10
lib/utils/cn.ts
Normal 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
12
next.config.ts
Normal 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
48
package.json
Normal 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
4492
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user