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