Skip to main content
Glama
LogsPage.tsx9.93 kB
import { useEffect, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { vscodeApi } from '../lib/vscode-api'; import type { ImplementationLogEntry } from '../lib/vscode-api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../components/ui/select'; import { Input } from '../components/ui/input'; import { Card, CardContent } from '../components/ui/card'; import { LogStatsPanel } from '../components/LogStatsPanel'; import { LogEntryCard } from '../components/LogEntryCard'; interface LogsData { specName: string; entries: ImplementationLogEntry[]; stats: { totalEntries: number; totalLinesAdded: number; totalLinesRemoved: number; totalFilesChanged: number; }; } type SortOption = 'timestamp' | 'taskId' | 'linesAdded' | 'filesChanged'; type SortOrder = 'asc' | 'desc'; export function LogsPage({ specs, selectedSpec, onSpecChange, }: { specs: any[]; selectedSpec: string | null; onSpecChange: (spec: string) => void; }) { const { t } = useTranslation(); const [logsData, setLogsData] = useState<LogsData | null>(null); const [searchQuery, setSearchQuery] = useState(''); const [filteredEntries, setFilteredEntries] = useState<ImplementationLogEntry[]>([]); const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set()); const [sortBy, setSortBy] = useState<SortOption>('timestamp'); const [sortOrder, setSortOrder] = useState<SortOrder>('desc'); const [isLoading, setIsLoading] = useState(false); const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined); // Listen for logs updates useEffect(() => { const unsubscribe = vscodeApi.onMessage('logs-updated', (message: any) => { if (message.data) { setLogsData(message.data); setSelectedTasks(new Set()); setSearchQuery(''); } }); return unsubscribe; }, []); // Listen for search results useEffect(() => { const unsubscribe = vscodeApi.onMessage('logs-search-results', (message: any) => { if (message.data) { const { entries } = message.data; applyFiltersAndSort(entries, new Set(), sortBy, sortOrder); setIsLoading(false); } }); return unsubscribe; }, [sortBy, sortOrder]); // Handle spec change useEffect(() => { if (selectedSpec) { setIsLoading(true); vscodeApi.getLogs(selectedSpec); } }, [selectedSpec]); // Stop loading after logs are received useEffect(() => { if (logsData) { setIsLoading(false); } }, [logsData]); // Apply filters and sorting useEffect(() => { if (logsData) { applyFiltersAndSort(logsData.entries, selectedTasks, sortBy, sortOrder); } }, [logsData, selectedTasks, sortBy, sortOrder]); // Debounced search useEffect(() => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } if (!selectedSpec || !searchQuery.trim()) { // If search is empty, reset to full entries with filters if (logsData) { applyFiltersAndSort(logsData.entries, selectedTasks, sortBy, sortOrder); } return; } setIsLoading(true); searchTimeoutRef.current = setTimeout(() => { vscodeApi.searchLogs(selectedSpec, searchQuery); }, 300); return () => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } }; }, [searchQuery, selectedSpec, logsData]); const applyFiltersAndSort = ( entries: ImplementationLogEntry[], tasks: Set<string>, sort: SortOption, order: SortOrder ) => { let filtered = [...entries]; // Filter by selected tasks if (tasks.size > 0) { filtered = filtered.filter(e => tasks.has(e.taskId)); } // Sort filtered.sort((a, b) => { let comparison = 0; switch (sort) { case 'timestamp': comparison = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); break; case 'taskId': comparison = a.taskId.localeCompare(b.taskId); break; case 'linesAdded': comparison = a.statistics.linesAdded - b.statistics.linesAdded; break; case 'filesChanged': comparison = a.statistics.filesChanged - b.statistics.filesChanged; break; } return order === 'desc' ? -comparison : comparison; }); setFilteredEntries(filtered); }; // Get unique task IDs for filter pills const uniqueTasks = logsData ? Array.from(new Set(logsData.entries.map(e => e.taskId))).sort() : []; const toggleTaskFilter = (taskId: string) => { const newTasks = new Set(selectedTasks); if (newTasks.has(taskId)) { newTasks.delete(taskId); } else { newTasks.add(taskId); } setSelectedTasks(newTasks); }; const toggleSort = (newSort: SortOption) => { if (sortBy === newSort) { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortBy(newSort); setSortOrder('desc'); } }; return ( <div className="space-y-6"> {/* Header */} <div> <h2 className="text-2xl font-bold">{t('logs.title')}</h2> <p className="text-sm text-muted-foreground mt-1"> {t('logs.subtitle')} </p> </div> {/* Controls */} <div className="space-y-4"> {/* Spec Selector */} <Select value={selectedSpec || ''} onValueChange={onSpecChange}> <SelectTrigger className="w-full"> <SelectValue placeholder={t('logs.selectSpec')} /> </SelectTrigger> <SelectContent> {specs.map((spec) => ( <SelectItem key={spec.name} value={spec.name}> {spec.displayName || spec.name} </SelectItem> ))} </SelectContent> </Select> {/* Search Input */} <Input placeholder={t('logs.searchPlaceholder')} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} disabled={!selectedSpec} /> {/* Task Filters */} {logsData && uniqueTasks.length > 0 && ( <div className="flex flex-wrap gap-2"> {uniqueTasks.map((taskId) => ( <button key={taskId} onClick={() => toggleTaskFilter(taskId)} className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${ selectedTasks.has(taskId) ? 'bg-blue-500 text-white' : 'bg-muted text-muted-foreground hover:bg-muted-foreground/20' }`} > {taskId} </button> ))} </div> )} {/* Sort Controls */} <div className="flex flex-wrap gap-2"> <button onClick={() => toggleSort('timestamp')} className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${ sortBy === 'timestamp' ? 'bg-blue-500 text-white' : 'bg-muted text-muted-foreground hover:bg-muted-foreground/20' }`} > {t('logs.sort.timestamp')} {sortBy === 'timestamp' && (sortOrder === 'asc' ? '↑' : '↓')} </button> <button onClick={() => toggleSort('taskId')} className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${ sortBy === 'taskId' ? 'bg-blue-500 text-white' : 'bg-muted text-muted-foreground hover:bg-muted-foreground/20' }`} > {t('logs.sort.taskId')} {sortBy === 'taskId' && (sortOrder === 'asc' ? '↑' : '↓')} </button> <button onClick={() => toggleSort('linesAdded')} className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${ sortBy === 'linesAdded' ? 'bg-blue-500 text-white' : 'bg-muted text-muted-foreground hover:bg-muted-foreground/20' }`} > {t('logs.sort.linesAdded')} {sortBy === 'linesAdded' && (sortOrder === 'asc' ? '↑' : '↓')} </button> <button onClick={() => toggleSort('filesChanged')} className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${ sortBy === 'filesChanged' ? 'bg-blue-500 text-white' : 'bg-muted text-muted-foreground hover:bg-muted-foreground/20' }`} > {t('logs.sort.filesChanged')} {sortBy === 'filesChanged' && (sortOrder === 'asc' ? '↑' : '↓')} </button> </div> </div> {/* Stats Panel */} {logsData && <LogStatsPanel stats={logsData.stats} />} {/* Loading State */} {isLoading && ( <Card> <CardContent className="p-8 text-center text-muted-foreground"> {t('logs.loading')} </CardContent> </Card> )} {/* Empty State */} {!isLoading && !selectedSpec && ( <Card> <CardContent className="p-8 text-center text-muted-foreground"> {t('logs.selectSpecMessage')} </CardContent> </Card> )} {!isLoading && selectedSpec && filteredEntries.length === 0 && ( <Card> <CardContent className="p-8 text-center text-muted-foreground"> {searchQuery ? t('logs.noSearchResults') : t('logs.noLogs')} </CardContent> </Card> )} {/* Log Entries */} {!isLoading && filteredEntries.length > 0 && ( <div className="space-y-2"> {filteredEntries.map((entry) => ( <LogEntryCard key={entry.id} entry={entry} /> ))} </div> )} </div> ); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Pimzino/spec-workflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server