diff --git a/server/pkg/instance.go b/server/pkg/instance.go index d99a896..0a2f038 100644 --- a/server/pkg/instance.go +++ b/server/pkg/instance.go @@ -38,7 +38,8 @@ type Instance struct { Running bool `json:"running"` // Log file - logFile *os.File `json:"-"` + logFile *os.File `json:"-"` + logFilePath string `json:"-"` // Store the log file path separately // internal 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 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" + + // 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) if err != nil { 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 func (i *Instance) GetLogs(num_lines int) (string, error) { i.mu.RLock() - logFileName := "" - if i.logFile != nil { - logFileName = i.logFile.Name() - } + logFileName := i.logFilePath i.mu.RUnlock() if logFileName == "" { diff --git a/ui/src/components/InstanceCard.tsx b/ui/src/components/InstanceCard.tsx index 1028345..3214eec 100644 --- a/ui/src/components/InstanceCard.tsx +++ b/ui/src/components/InstanceCard.tsx @@ -1,106 +1,126 @@ -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Instance } from '@/types/instance' -import { Edit, FileText, Play, Square, Trash2 } from 'lucide-react' +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Instance } from "@/types/instance"; +import { Edit, FileText, Play, Square, Trash2 } from "lucide-react"; +import LogsModal from "@/components/ui/LogModal"; +import { useState } from "react"; interface InstanceCardProps { - instance: Instance - startInstance: (name: string) => void - stopInstance: (name: string) => void - deleteInstance: (name: string) => void - editInstance: (instance: Instance) => void + instance: Instance; + startInstance: (name: string) => void; + stopInstance: (name: string) => void; + deleteInstance: (name: string) => 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 = () => { - startInstance(instance.name) - } + startInstance(instance.name); + }; const handleStop = () => { - stopInstance(instance.name) - } + stopInstance(instance.name); + }; const handleDelete = () => { - if (confirm(`Are you sure you want to delete instance "${instance.name}"?`)) { - deleteInstance(instance.name) + if ( + confirm(`Are you sure you want to delete instance "${instance.name}"?`) + ) { + deleteInstance(instance.name); } - } + }; const handleEdit = () => { - editInstance(instance) - } + editInstance(instance); + }; const handleLogs = () => { - // Logic for viewing logs (e.g., open a logs page) - console.log(`View logs for instance: ${instance.name}`) - } + setIsLogsOpen(true); + }; return ( - - -
- {instance.name} - - {instance.running ? "Running" : "Stopped"} - -
-
- - -
- - - - - - - - - -
-
-
- ) + <> + + +
+ {instance.name} + + {instance.running ? "Running" : "Stopped"} + +
+
+ + +
+ + + + + + + + + +
+
+
+ + + + ); } -export default InstanceCard \ No newline at end of file +export default InstanceCard; diff --git a/ui/src/components/ui/LogModal.tsx b/ui/src/components/ui/LogModal.tsx new file mode 100644 index 0000000..9d16f68 --- /dev/null +++ b/ui/src/components/ui/LogModal.tsx @@ -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 = ({ + open, + onOpenChange, + instanceName, + isRunning +}) => { + const [logs, setLogs] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [lineCount, setLineCount] = useState(100) + const [autoRefresh, setAutoRefresh] = useState(false) + const [copied, setCopied] = useState(false) + const [showSettings, setShowSettings] = useState(false) + + const logContainerRef = useRef(null) + const refreshIntervalRef = useRef(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 ( +
+ {line || '\u00A0'} {/* Non-breaking space for empty lines */} +
+ ) + }) + } + + return ( + + + +
+
+ + Logs: {instanceName} + + {isRunning ? "Running" : "Stopped"} + + + + Instance logs and output + +
+ +
+ + + +
+
+
+ + {/* Settings Panel */} + {showSettings && ( +
+
+
+ + handleLineCountChange(e.target.value)} + className="w-24" + min="1" + max="10000" + /> +
+ +
+ setAutoRefresh(e.target.checked)} + disabled={!isRunning} + className="rounded" + /> + +
+ + +
+
+ )} + + {/* Log Content */} +
+ {error && ( +
+ + {error} +
+ )} + +
+ {loading && !logs ? ( +
+ + Loading logs... +
+ ) : logs ? ( +
+ {formatLogs(logs)} +
+ ) : ( +
+ No logs available +
+ )} +
+ + {autoRefresh && isRunning && ( +
+
+ Auto-refreshing every 2 seconds +
+ )} +
+ + +
+ + + + +
+ + +
+ + +
+ ) +} + +export default LogsModal \ No newline at end of file