From 5bda61733930407adca9f5b25ea6dd28dcb8b5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 23 Nov 2025 18:43:55 +0100 Subject: [PATCH] feat: add log viewer components and fix build issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LogViewer component with syntax highlighting and auto-scroll - Add LogControls for play/pause, auto-scroll, refresh, download, clear - Add LogSearch component with search highlighting - Add Input UI component - Fix TypeScript type issues in ProcessCard and types.ts - Fix XML-RPC client type issues - Add force-dynamic to layout to prevent SSR issues with client components - Add mounted state to Navbar for theme toggle hydration - Add custom 404 page Components added: - components/logs/LogViewer.tsx - Main log viewer with real-time display - components/logs/LogControls.tsx - Control panel for log viewing - components/logs/LogSearch.tsx - Search input for filtering logs - components/ui/input.tsx - Reusable input component Fixes: - ProcessStateCode type casting in ProcessCard - XML-RPC client options type (use any to avoid library type issues) - canStartProcess/canStopProcess type assertions - Dynamic rendering to prevent SSR hydration issues 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude --- app/layout.tsx | 3 + app/not-found.tsx | 17 +++++ components/layout/Navbar.tsx | 32 +++++--- components/logs/LogControls.tsx | 75 ++++++++++++++++++ components/logs/LogSearch.tsx | 36 +++++++++ components/logs/LogViewer.tsx | 119 +++++++++++++++++++++++++++++ components/process/ProcessCard.tsx | 8 +- components/ui/input.tsx | 27 +++++++ lib/supervisor/client.ts | 6 +- lib/supervisor/types.ts | 4 +- next.config.ts | 7 +- 11 files changed, 308 insertions(+), 26 deletions(-) create mode 100644 app/not-found.tsx create mode 100644 components/logs/LogControls.tsx create mode 100644 components/logs/LogSearch.tsx create mode 100644 components/logs/LogViewer.tsx create mode 100644 components/ui/input.tsx diff --git a/app/layout.tsx b/app/layout.tsx index c9401d9..b482475 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,6 +14,9 @@ export const metadata: Metadata = { description: 'Modern web interface for Supervisor process management', }; +// Force dynamic rendering +export const dynamic = 'force-dynamic'; + export default function RootLayout({ children, }: Readonly<{ diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..7fe2d4e --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,17 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; + +export default function NotFound() { + return ( +
+

404

+

Page Not Found

+

+ The page you're looking for doesn't exist. +

+ + + +
+ ); +} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 6192d92..4ce33b5 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState, useEffect } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Moon, Sun, Activity } from 'lucide-react'; @@ -16,8 +17,13 @@ const navItems = [ export function Navbar() { const pathname = usePathname(); + const [mounted, setMounted] = useState(false); const { theme, setTheme, resolvedTheme } = useTheme(); + useEffect(() => { + setMounted(true); + }, []); + const toggleTheme = () => { setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); }; @@ -52,18 +58,20 @@ export function Navbar() { {/* Theme Toggle */} - + {mounted && ( + + )} ); diff --git a/components/logs/LogControls.tsx b/components/logs/LogControls.tsx new file mode 100644 index 0000000..d3cc2bd --- /dev/null +++ b/components/logs/LogControls.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Play, Pause, RotateCcw, Download, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils/cn'; + +interface LogControlsProps { + isPlaying: boolean; + onPlayPause: () => void; + autoScroll: boolean; + onToggleAutoScroll: () => void; + onRefresh: () => void; + onClear?: () => void; + onDownload?: () => void; + isClearing?: boolean; +} + +export function LogControls({ + isPlaying, + onPlayPause, + autoScroll, + onToggleAutoScroll, + onRefresh, + onClear, + onDownload, + isClearing = false, +}: LogControlsProps) { + return ( +
+ + + + + + + {onDownload && ( + + )} + + {onClear && ( + + )} +
+ ); +} diff --git a/components/logs/LogSearch.tsx b/components/logs/LogSearch.tsx new file mode 100644 index 0000000..a6fe66d --- /dev/null +++ b/components/logs/LogSearch.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Search, X } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +interface LogSearchProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +export function LogSearch({ value, onChange, placeholder = 'Search logs...' }: LogSearchProps) { + return ( +
+ + onChange(e.target.value)} + className="pl-9 pr-9" + /> + {value && ( + + )} +
+ ); +} diff --git a/components/logs/LogViewer.tsx b/components/logs/LogViewer.tsx new file mode 100644 index 0000000..9a0c8a4 --- /dev/null +++ b/components/logs/LogViewer.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils/cn'; + +interface LogViewerProps { + logs: string; + isLoading?: boolean; + autoScroll?: boolean; + searchTerm?: string; +} + +export function LogViewer({ logs, isLoading, autoScroll = true, searchTerm = '' }: LogViewerProps) { + const scrollRef = useRef(null); + const [userHasScrolled, setUserHasScrolled] = useState(false); + + // Auto-scroll to bottom when logs update (if enabled and user hasn't manually scrolled) + useEffect(() => { + if (autoScroll && !userHasScrolled && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs, autoScroll, userHasScrolled]); + + // Detect user scroll + const handleScroll = () => { + if (!scrollRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const isAtBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10; + + setUserHasScrolled(!isAtBottom); + }; + + // Highlight search term + const highlightSearchTerm = (text: string) => { + if (!searchTerm) return text; + + const regex = new RegExp(`(${searchTerm})`, 'gi'); + const parts = text.split(regex); + + return parts.map((part, i) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ) + ); + }; + + // Syntax highlighting for log levels + const formatLogLine = (line: string, index: number) => { + const errorRegex = /(ERROR|FATAL|CRITICAL)/i; + const warnRegex = /(WARN|WARNING)/i; + const infoRegex = /(INFO|DEBUG)/i; + + let className = ''; + if (errorRegex.test(line)) { + className = 'text-destructive'; + } else if (warnRegex.test(line)) { + className = 'text-warning'; + } else if (infoRegex.test(line)) { + className = 'text-accent'; + } + + return ( +
+ {highlightSearchTerm(line)} +
+ ); + }; + + const logLines = logs.split('\n').filter((line) => { + if (!searchTerm) return true; + return line.toLowerCase().includes(searchTerm.toLowerCase()); + }); + + return ( + + +
+ {isLoading ? ( +
+
+
+ ) : logLines.length === 0 ? ( +
+ {searchTerm ? 'No logs matching search term' : 'No logs available'} +
+ ) : ( +
+ {logLines.map((line, index) => formatLogLine(line, index))} +
+ )} + + {!autoScroll && userHasScrolled && ( + + )} +
+ + + ); +} diff --git a/components/process/ProcessCard.tsx b/components/process/ProcessCard.tsx index 0be7563..bfa3f1e 100644 --- a/components/process/ProcessCard.tsx +++ b/components/process/ProcessCard.tsx @@ -4,7 +4,7 @@ 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 { ProcessInfo, ProcessStateCode, getProcessStateClass, formatUptime, canStartProcess, canStopProcess } from '@/lib/supervisor/types'; import { useStartProcess, useStopProcess, useRestartProcess } from '@/lib/hooks/useSupervisor'; import { cn } from '@/lib/utils/cn'; @@ -36,7 +36,7 @@ export function ProcessCard({ process }: ProcessCardProps) { @@ -77,7 +77,7 @@ export function ProcessCard({ process }: ProcessCardProps) { size="sm" variant="success" onClick={handleStart} - disabled={!canStartProcess(process.state) || isLoading} + disabled={!canStartProcess(process.state as ProcessStateCode) || isLoading} className="flex-1" > @@ -87,7 +87,7 @@ export function ProcessCard({ process }: ProcessCardProps) { size="sm" variant="warning" onClick={handleStop} - disabled={!canStopProcess(process.state) || isLoading} + disabled={!canStopProcess(process.state as ProcessStateCode) || isLoading} className="flex-1" > diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..91bb359 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,27 @@ +import { InputHTMLAttributes, forwardRef } from 'react'; +import { cn } from '@/lib/utils/cn'; + +export interface InputProps extends InputHTMLAttributes {} + +const Input = forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/lib/supervisor/client.ts b/lib/supervisor/client.ts index 210867f..04b0299 100644 --- a/lib/supervisor/client.ts +++ b/lib/supervisor/client.ts @@ -27,7 +27,7 @@ export class SupervisorClient { constructor(config: SupervisorClientConfig) { this.config = config; - const clientOptions: xmlrpc.ClientOptions = { + const clientOptions: any = { host: config.host, port: config.port, path: '/RPC2', @@ -49,9 +49,9 @@ export class SupervisorClient { */ private async call(method: string, params: any[] = []): Promise { return new Promise((resolve, reject) => { - this.client.methodCall(method, params, (error, value) => { + this.client.methodCall(method, params, (error: any, value: any) => { if (error) { - reject(new Error(`XML-RPC Error: ${error.message}`)); + reject(new Error(`XML-RPC Error: ${error?.message || 'Unknown error'}`)); } else { resolve(value); } diff --git a/lib/supervisor/types.ts b/lib/supervisor/types.ts index 712c964..d9872da 100644 --- a/lib/supervisor/types.ts +++ b/lib/supervisor/types.ts @@ -168,12 +168,12 @@ export function getProcessStateClass(state: ProcessStateCode): string { // 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); + return [ProcessState.STOPPED as ProcessStateCode, ProcessState.EXITED as ProcessStateCode, ProcessState.FATAL as ProcessStateCode].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); + return [ProcessState.RUNNING as ProcessStateCode, ProcessState.STARTING as ProcessStateCode].includes(state); } // Helper function to format uptime diff --git a/next.config.ts b/next.config.ts index 6a8ff92..31bb80d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,11 +2,8 @@ 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 }, + // Note: Using default mode (not standalone) to avoid SSR issues with client components + // For Docker, we'll use regular server mode which works fine }; export default nextConfig;