feat: add log viewer components and fix build issues
Some checks failed
Build and Push Docker Image to Gitea / build-and-push (push) Failing after 53s

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-11-23 18:43:55 +01:00
parent e0cfd371c0
commit 5bda617339
11 changed files with 308 additions and 26 deletions

View File

@@ -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() {
</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>
{mounted && (
<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>
);

View File

@@ -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 (
<div className="flex items-center gap-2">
<Button
variant={isPlaying ? 'destructive' : 'success'}
size="sm"
onClick={onPlayPause}
title={isPlaying ? 'Pause auto-refresh' : 'Resume auto-refresh'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{isPlaying ? 'Pause' : 'Play'}
</Button>
<Button
variant={autoScroll ? 'default' : 'outline'}
size="sm"
onClick={onToggleAutoScroll}
title={autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll'}
>
Auto-scroll
</Button>
<Button variant="outline" size="sm" onClick={onRefresh} title="Refresh logs now">
<RotateCcw className="h-4 w-4" />
Refresh
</Button>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload} title="Download logs">
<Download className="h-4 w-4" />
Download
</Button>
)}
{onClear && (
<Button
variant="destructive"
size="sm"
onClick={onClear}
disabled={isClearing}
title="Clear logs"
>
<Trash2 className={cn('h-4 w-4', isClearing && 'animate-spin')} />
Clear
</Button>
)}
</div>
);
}

View File

@@ -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 (
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-9 pr-9"
/>
{value && (
<Button
variant="ghost"
size="sm"
onClick={() => onChange('')}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@@ -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<HTMLDivElement>(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) ? (
<mark key={i} className="bg-warning/30 text-warning-foreground">
{part}
</mark>
) : (
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 (
<div key={index} className={cn('font-mono text-xs leading-relaxed', className)}>
{highlightSearchTerm(line)}
</div>
);
};
const logLines = logs.split('\n').filter((line) => {
if (!searchTerm) return true;
return line.toLowerCase().includes(searchTerm.toLowerCase());
});
return (
<Card className="h-full">
<CardContent className="p-0 h-full">
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-full overflow-auto p-4 bg-muted/30 rounded-lg"
>
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
) : logLines.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
{searchTerm ? 'No logs matching search term' : 'No logs available'}
</div>
) : (
<div className="space-y-0.5">
{logLines.map((line, index) => formatLogLine(line, index))}
</div>
)}
{!autoScroll && userHasScrolled && (
<button
onClick={() => {
setUserHasScrolled(false);
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}}
className="fixed bottom-20 right-8 px-4 py-2 bg-primary text-primary-foreground rounded-lg shadow-lg hover:bg-primary/90 transition-colors text-sm font-medium"
>
Scroll to bottom
</button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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) {
<Badge
className={cn(
'ml-2',
getProcessStateClass(process.state),
getProcessStateClass(process.state as ProcessStateCode),
'border px-3 py-1 font-mono text-xs'
)}
>
@@ -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"
>
<Play className="h-4 w-4" />
@@ -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"
>
<Square className="h-4 w-4" />

27
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { InputHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils/cn';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };