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
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:
75
components/logs/LogControls.tsx
Normal file
75
components/logs/LogControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/logs/LogSearch.tsx
Normal file
36
components/logs/LogSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
components/logs/LogViewer.tsx
Normal file
119
components/logs/LogViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user