Add log file path handling and implement LogsModal component for instance logs

This commit is contained in:
2025-07-23 21:08:13 +02:00
parent ac57bd770f
commit 59061edb96
3 changed files with 457 additions and 92 deletions

View File

@@ -39,6 +39,7 @@ type Instance struct {
// Log file // Log file
logFile *os.File `json:"-"` logFile *os.File `json:"-"`
logFilePath string `json:"-"` // Store the log file path separately
// internal // internal
cmd *exec.Cmd `json:"-"` // Command to run the instance cmd *exec.Cmd `json:"-"` // Command to run the instance
@@ -136,7 +137,24 @@ func NewInstance(name string, globalSettings *InstancesConfig, options *CreateIn
// createLogFile creates and opens the log files for stdout and stderr // createLogFile creates and opens the log files for stdout and stderr
func (i *Instance) createLogFile() error { func (i *Instance) createLogFile() error {
if i.globalSettings == nil {
return fmt.Errorf("globalSettings is nil for instance %s", i.Name)
}
if i.globalSettings.LogDirectory == "" {
return fmt.Errorf("LogDirectory is empty for instance %s", i.Name)
}
logPath := i.globalSettings.LogDirectory + "/" + i.Name + ".log" logPath := i.globalSettings.LogDirectory + "/" + i.Name + ".log"
// Store the log file path for later access
i.logFilePath = logPath
// Check if directory exists, create if not
if err := os.MkdirAll(i.globalSettings.LogDirectory, 0755); err != nil {
return fmt.Errorf("failed to create log directory: %w", err)
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil { if err != nil {
return fmt.Errorf("failed to create stdout log file: %w", err) return fmt.Errorf("failed to create stdout log file: %w", err)
@@ -342,10 +360,7 @@ func (i *Instance) Stop() error {
// GetLogs retrieves the last n lines of logs from the instance // GetLogs retrieves the last n lines of logs from the instance
func (i *Instance) GetLogs(num_lines int) (string, error) { func (i *Instance) GetLogs(num_lines int) (string, error) {
i.mu.RLock() i.mu.RLock()
logFileName := "" logFileName := i.logFilePath
if i.logFile != nil {
logFileName = i.logFile.Name()
}
i.mu.RUnlock() i.mu.RUnlock()
if logFileName == "" { if logFileName == "" {

View File

@@ -1,43 +1,55 @@
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Instance } from '@/types/instance' import { Instance } from "@/types/instance";
import { Edit, FileText, Play, Square, Trash2 } from 'lucide-react' import { Edit, FileText, Play, Square, Trash2 } from "lucide-react";
import LogsModal from "@/components/ui/LogModal";
import { useState } from "react";
interface InstanceCardProps { interface InstanceCardProps {
instance: Instance instance: Instance;
startInstance: (name: string) => void startInstance: (name: string) => void;
stopInstance: (name: string) => void stopInstance: (name: string) => void;
deleteInstance: (name: string) => void deleteInstance: (name: string) => void;
editInstance: (instance: Instance) => void editInstance: (instance: Instance) => void;
} }
function InstanceCard({ instance, startInstance, stopInstance, deleteInstance, editInstance }: InstanceCardProps) { function InstanceCard({
instance,
startInstance,
stopInstance,
deleteInstance,
editInstance,
}: InstanceCardProps) {
const [isLogsOpen, setIsLogsOpen] = useState(false);
const handleStart = () => { const handleStart = () => {
startInstance(instance.name) startInstance(instance.name);
} };
const handleStop = () => { const handleStop = () => {
stopInstance(instance.name) stopInstance(instance.name);
} };
const handleDelete = () => { const handleDelete = () => {
if (confirm(`Are you sure you want to delete instance "${instance.name}"?`)) { if (
deleteInstance(instance.name) confirm(`Are you sure you want to delete instance "${instance.name}"?`)
} ) {
deleteInstance(instance.name);
} }
};
const handleEdit = () => { const handleEdit = () => {
editInstance(instance) editInstance(instance);
} };
const handleLogs = () => { const handleLogs = () => {
// Logic for viewing logs (e.g., open a logs page) setIsLogsOpen(true);
console.log(`View logs for instance: ${instance.name}`) };
}
return ( return (
<>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -100,7 +112,15 @@ function InstanceCard({ instance, startInstance, stopInstance, deleteInstance, e
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)
<LogsModal
open={isLogsOpen}
onOpenChange={setIsLogsOpen}
instanceName={instance.name}
isRunning={instance.running}
/>
</>
);
} }
export default InstanceCard export default InstanceCard;

View File

@@ -0,0 +1,330 @@
import React, { useState, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import {
RefreshCw,
Download,
Copy,
CheckCircle,
AlertCircle,
Loader2,
Settings
} from 'lucide-react'
interface LogsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
instanceName: string
isRunning: boolean
}
const LogsModal: React.FC<LogsModalProps> = ({
open,
onOpenChange,
instanceName,
isRunning
}) => {
const [logs, setLogs] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lineCount, setLineCount] = useState(100)
const [autoRefresh, setAutoRefresh] = useState(false)
const [copied, setCopied] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const logContainerRef = useRef<HTMLDivElement>(null)
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null)
// Fetch logs function
const fetchLogs = async (lines?: number) => {
if (!instanceName) return
setLoading(true)
setError(null)
try {
const params = lines ? `?lines=${lines}` : ''
const response = await fetch(`/api/v1/instances/${instanceName}/logs${params}`)
if (!response.ok) {
throw new Error(`Failed to fetch logs: ${response.status}`)
}
const logText = await response.text()
setLogs(logText)
// Auto-scroll to bottom
setTimeout(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, 100)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch logs')
} finally {
setLoading(false)
}
}
// Initial load when modal opens
useEffect(() => {
if (open && instanceName) {
fetchLogs(lineCount)
}
}, [open, instanceName])
// Auto-refresh effect
useEffect(() => {
if (autoRefresh && isRunning && open) {
refreshIntervalRef.current = setInterval(() => {
fetchLogs(lineCount)
}, 2000) // Refresh every 2 seconds
} else {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current)
refreshIntervalRef.current = null
}
}
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current)
}
}
}, [autoRefresh, isRunning, open, lineCount])
// Copy logs to clipboard
const copyLogs = async () => {
try {
await navigator.clipboard.writeText(logs)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy logs:', err)
}
}
// Download logs as file
const downloadLogs = () => {
const blob = new Blob([logs], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${instanceName}-logs.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// Handle line count change
const handleLineCountChange = (value: string) => {
const num = parseInt(value) || 100
setLineCount(num)
}
// Apply new line count
const applyLineCount = () => {
fetchLogs(lineCount)
setShowSettings(false)
}
// Format logs with basic syntax highlighting
const formatLogs = (logText: string) => {
if (!logText) return ''
return logText.split('\n').map((line, index) => {
let className = 'font-mono text-sm leading-relaxed'
// Basic log level detection
if (line.includes('ERROR') || line.includes('[ERROR]')) {
className += ' text-red-400'
} else if (line.includes('WARN') || line.includes('[WARN]')) {
className += ' text-yellow-400'
} else if (line.includes('INFO') || line.includes('[INFO]')) {
className += ' text-blue-400'
} else if (line.includes('DEBUG') || line.includes('[DEBUG]')) {
className += ' text-gray-400'
} else if (line.includes('===')) {
className += ' text-green-400 font-semibold'
} else {
className += ' text-gray-300'
}
return (
<div key={index} className={className}>
{line || '\u00A0'} {/* Non-breaking space for empty lines */}
</div>
)
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between">
<div>
<DialogTitle className="flex items-center gap-2">
Logs: {instanceName}
<Badge variant={isRunning ? "default" : "secondary"}>
{isRunning ? "Running" : "Stopped"}
</Badge>
</DialogTitle>
<DialogDescription>
Instance logs and output
</DialogDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowSettings(!showSettings)}
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fetchLogs(lineCount)}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
</div>
</DialogHeader>
{/* Settings Panel */}
{showSettings && (
<div className="border rounded-lg p-4 bg-muted/50">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="lineCount">Lines:</Label>
<Input
id="lineCount"
type="number"
value={lineCount}
onChange={(e) => handleLineCountChange(e.target.value)}
className="w-24"
min="1"
max="10000"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoRefresh"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
disabled={!isRunning}
className="rounded"
/>
<Label htmlFor="autoRefresh">
Auto-refresh {!isRunning && '(instance not running)'}
</Label>
</div>
<Button size="sm" onClick={applyLineCount}>
Apply
</Button>
</div>
</div>
)}
{/* Log Content */}
<div className="flex-1 flex flex-col min-h-0">
{error && (
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg mb-4">
<AlertCircle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">{error}</span>
</div>
)}
<div
ref={logContainerRef}
className="flex-1 bg-gray-900 rounded-lg p-4 overflow-auto min-h-[400px] max-h-[500px]"
>
{loading && !logs ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
<span className="ml-2 text-gray-400">Loading logs...</span>
</div>
) : logs ? (
<div className="whitespace-pre-wrap">
{formatLogs(logs)}
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
No logs available
</div>
)}
</div>
{autoRefresh && isRunning && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
Auto-refreshing every 2 seconds
</div>
)}
</div>
<DialogFooter className="flex-shrink-0">
<div className="flex items-center gap-2 w-full">
<Button
variant="outline"
onClick={copyLogs}
disabled={!logs}
>
{copied ? (
<>
<CheckCircle className="h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
<Button
variant="outline"
onClick={downloadLogs}
disabled={!logs}
>
<Download className="h-4 w-4" />
Download
</Button>
<div className="flex-1" />
<Button onClick={() => onOpenChange(false)}>
Close
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default LogsModal