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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

329
app/globals.css Normal file
View File

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

50
app/layout.tsx Normal file
View File

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

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

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

135
app/page.tsx Normal file
View File

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

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

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