182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { DashboardLayout } from '../Layout';
|
|
import { Card } from '@/components/Card';
|
|
import { MessageBubble } from '../Chat/MessageBubble';
|
|
import type { ChatMessage } from '../Chat/types';
|
|
|
|
type LogMetadata = {
|
|
filename: string;
|
|
taskName: string;
|
|
taskDirName: string;
|
|
entryName: string;
|
|
entryType: 'file' | 'directory';
|
|
provider: string;
|
|
model: string;
|
|
startedAt: string;
|
|
completedAt: string | null;
|
|
isError: boolean;
|
|
};
|
|
|
|
type FullLog = LogMetadata & {
|
|
messages: ChatMessage[];
|
|
};
|
|
|
|
const formatDate = (iso: string) => {
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
const ProviderBadge = ({ provider }: { provider: string }) => (
|
|
<span
|
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${provider === 'claude' ? 'bg-orange-100 text-orange-700' : 'bg-blue-100 text-blue-700'}`}
|
|
>
|
|
{provider}
|
|
</span>
|
|
);
|
|
|
|
export const TaskLogs = () => {
|
|
const client = useClient();
|
|
const [logs, setLogs] = useState<LogMetadata[]>([]);
|
|
const [selectedFilename, setSelectedFilename] = useState<string | null>(null);
|
|
const [showDetail, setShowDetail] = useState(false);
|
|
const [selectedLog, setSelectedLog] = useState<FullLog | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
client
|
|
.get<LogMetadata[]>('/task-logs')
|
|
.then((data) => {
|
|
setLogs(data);
|
|
setIsLoading(false);
|
|
})
|
|
.catch(() => setIsLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!selectedFilename) {
|
|
setSelectedLog(null);
|
|
return;
|
|
}
|
|
client
|
|
.get<FullLog>(`/task-logs/${selectedFilename}`)
|
|
.then(setSelectedLog)
|
|
.catch(() => setSelectedLog(null));
|
|
}, [selectedFilename]);
|
|
|
|
const filtered = search
|
|
? logs.filter((l) => {
|
|
const q = search.toLowerCase();
|
|
return (
|
|
l.taskName.toLowerCase().includes(q) ||
|
|
l.entryName.toLowerCase().includes(q) ||
|
|
l.provider.toLowerCase().includes(q)
|
|
);
|
|
})
|
|
: logs;
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="flex h-full p-3 md:p-6 gap-4">
|
|
{/* Left panel: list */}
|
|
<Card
|
|
className={`md:w-80 shrink-0 flex flex-col overflow-hidden ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
|
>
|
|
<div className="p-3 border-b border-duck-dark/10">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search logs..."
|
|
value={search}
|
|
onChange={(ev) => setSearch(ev.target.value)}
|
|
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-white/60 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{isLoading && (
|
|
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">Loading...</div>
|
|
)}
|
|
{!isLoading && filtered.length === 0 && (
|
|
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">No logs found</div>
|
|
)}
|
|
{filtered.map((log) => (
|
|
<button
|
|
key={log.filename}
|
|
onClick={() => {
|
|
setSelectedFilename(log.filename);
|
|
setShowDetail(true);
|
|
}}
|
|
className={`w-full text-left px-3 py-2.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${selectedFilename === log.filename ? 'bg-duck-teal/10' : ''}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
{log.isError ? (
|
|
<AlertCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
|
) : log.completedAt ? (
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
|
) : (
|
|
<Clock className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
|
)}
|
|
<span className="text-sm font-medium text-duck-dark truncate">{log.taskName}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 ml-5.5">
|
|
<span className="text-xs text-duck-dark/50 truncate">{log.entryName}</span>
|
|
<ProviderBadge provider={log.provider} />
|
|
</div>
|
|
<div className="text-[10px] text-duck-dark/40 ml-5.5 mt-0.5">{formatDate(log.startedAt)}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Right panel: log viewer */}
|
|
<Card className={`flex-1 min-w-0 flex flex-col overflow-hidden ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
|
{!selectedLog && (
|
|
<div className="flex flex-col items-center justify-center h-full text-duck-dark/30 text-sm gap-2">
|
|
Select a log to view
|
|
<button onClick={() => setShowDetail(false)} className="md:hidden text-duck-teal text-xs cursor-pointer">
|
|
<ArrowLeft className="h-4 w-4 inline mr-1" />
|
|
Back to list
|
|
</button>
|
|
</div>
|
|
)}
|
|
{selectedLog && (
|
|
<>
|
|
<div className="shrink-0 px-4 py-3 border-b border-duck-dark/10 flex items-center gap-3">
|
|
<button
|
|
onClick={() => setShowDetail(false)}
|
|
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-duck-dark">{selectedLog.taskName}</span>
|
|
<ProviderBadge provider={selectedLog.provider} />
|
|
</div>
|
|
<div className="text-xs text-duck-dark/50 mt-0.5">
|
|
{selectedLog.entryName} · {selectedLog.model} · {formatDate(selectedLog.startedAt)}
|
|
{selectedLog.completedAt && ` — ${formatDate(selectedLog.completedAt)}`}
|
|
</div>
|
|
</div>
|
|
{selectedLog.isError && (
|
|
<span className="text-xs text-red-600 bg-red-50 px-2 py-0.5 rounded-full">Error</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
{selectedLog.messages.map((msg, i) => (
|
|
<MessageBubble key={i} message={msg} />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
};
|