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

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

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

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

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

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

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

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

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