185 lines
6.1 KiB
TypeScript
185 lines
6.1 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { Terminal, RefreshCw, Download, Pause, Play } from 'lucide-react'
|
|
import { Card, CardHeader } from '../components/Card'
|
|
import { fetchSkills, fetchLogs } from '../lib/api'
|
|
|
|
export function Logs() {
|
|
const [selectedSkill, setSelectedSkill] = useState<string>('all')
|
|
const [isPaused, setIsPaused] = useState(false)
|
|
const [filter, setFilter] = useState('')
|
|
const logsEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
const { data: skills } = useQuery({
|
|
queryKey: ['skills'],
|
|
queryFn: fetchSkills,
|
|
})
|
|
|
|
const { data: logs, refetch } = useQuery({
|
|
queryKey: ['logs', selectedSkill],
|
|
queryFn: () => fetchLogs(selectedSkill, 200),
|
|
refetchInterval: isPaused ? false : 2000,
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!isPaused) {
|
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
}, [logs, isPaused])
|
|
|
|
const filteredLogs = logs?.filter(line =>
|
|
filter ? line.toLowerCase().includes(filter.toLowerCase()) : true
|
|
)
|
|
|
|
const downloadLogs = () => {
|
|
if (!logs) return
|
|
const blob = new Blob([logs.join('\n')], { type: 'text/plain' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${selectedSkill}-logs-${new Date().toISOString()}.txt`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 h-full flex flex-col">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-white">Logs</h1>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setIsPaused(!isPaused)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
|
isPaused
|
|
? 'bg-green-600 hover:bg-green-700 text-white'
|
|
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
|
|
}`}
|
|
>
|
|
{isPaused ? (
|
|
<>
|
|
<Play className="w-4 h-4" />
|
|
Resume
|
|
</>
|
|
) : (
|
|
<>
|
|
<Pause className="w-4 h-4" />
|
|
Pause
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Refresh
|
|
</button>
|
|
<button
|
|
onClick={downloadLogs}
|
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card className="flex-shrink-0">
|
|
<div className="flex gap-4">
|
|
<div className="flex-1">
|
|
<label className="block text-xs text-gray-500 mb-1">Skill</label>
|
|
<select
|
|
value={selectedSkill}
|
|
onChange={(e) => setSelectedSkill(e.target.value)}
|
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-purple-500"
|
|
>
|
|
<option value="all">All Skills</option>
|
|
{skills?.map((skill) => (
|
|
<option key={skill.name} value={skill.name}>
|
|
{skill.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex-1">
|
|
<label className="block text-xs text-gray-500 mb-1">Filter</label>
|
|
<input
|
|
type="text"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
placeholder="Filter logs..."
|
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Log Output */}
|
|
<Card className="flex-1 min-h-0 flex flex-col">
|
|
<CardHeader
|
|
title={
|
|
<div className="flex items-center gap-2">
|
|
<Terminal className="w-4 h-4 text-gray-500" />
|
|
<span>Output</span>
|
|
{!isPaused && (
|
|
<span className="flex items-center gap-1 text-xs text-green-400">
|
|
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
|
Live
|
|
</span>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="flex-1 bg-gray-950 rounded-lg p-4 font-mono text-xs overflow-auto">
|
|
{filteredLogs?.length === 0 ? (
|
|
<p className="text-gray-500">No logs available</p>
|
|
) : (
|
|
<>
|
|
{filteredLogs?.map((line, i) => (
|
|
<LogLine key={i} line={line} filter={filter} />
|
|
))}
|
|
<div ref={logsEndRef} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LogLine({ line, filter }: { line: string; filter: string }) {
|
|
// Detect log level
|
|
const isError = /error|exception|fail/i.test(line)
|
|
const isWarning = /warn/i.test(line)
|
|
const isInfo = /info/i.test(line)
|
|
const isDebug = /debug/i.test(line)
|
|
|
|
let colorClass = 'text-gray-400'
|
|
if (isError) colorClass = 'text-red-400'
|
|
else if (isWarning) colorClass = 'text-yellow-400'
|
|
else if (isInfo) colorClass = 'text-blue-400'
|
|
else if (isDebug) colorClass = 'text-gray-500'
|
|
|
|
// Highlight filter matches
|
|
if (filter) {
|
|
const regex = new RegExp(`(${filter})`, 'gi')
|
|
const parts = line.split(regex)
|
|
return (
|
|
<div className={`${colorClass} hover:bg-gray-900/50`}>
|
|
{parts.map((part, i) =>
|
|
part.toLowerCase() === filter.toLowerCase() ? (
|
|
<span key={i} className="bg-yellow-500/30 text-yellow-200">
|
|
{part}
|
|
</span>
|
|
) : (
|
|
part
|
|
)
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return <div className={`${colorClass} hover:bg-gray-900/50`}>{line}</div>
|
|
}
|