mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-30 07:37:39 +00:00
3ccd21da3f
This PR combines and improves upon features from PR #228 (log search) and PR #229 (usage analytics dashboard) by creating a unified, well-architected solution with shared infrastructure and clear separation of concerns. ## Backend Changes ### New Module: routstr/logs/ - reader.py: Shared log file reading/parsing infrastructure - parse_log_file(): Parse JSON log files - get_log_files_in_range(): Get relevant log files by date/time - filter_entries_by_time(): Filter entries by time range - get_available_log_dates(): Get list of available log dates - search.py: Generic log search with filtering - search_logs(): Search with filters (date, level, request_id, text) - Supports case-insensitive text search - Configurable result limits - metrics.py: Usage analytics metrics extraction - aggregate_metrics_by_time(): Time-series data aggregation - get_summary_stats(): Summary statistics - get_error_details(): Detailed error information - get_revenue_by_model(): Revenue breakdown by model ### Updated: routstr/core/admin.py Added API endpoints: - GET /admin/api/logs - Filtered log entries - GET /admin/api/logs/dates - Available log dates - GET /admin/api/usage/metrics - Time-series metrics - GET /admin/api/usage/summary - Summary statistics - GET /admin/api/usage/error-details - Error details - GET /admin/api/usage/revenue-by-model - Revenue by model - GET /admin/logs - Redirect to logs frontend - GET /admin/usage - Redirect to usage frontend ## Frontend Changes ### New Page: /logs (Log Search) - app/logs/page.tsx: Main logs page with filtering - app/logs/types.ts: TypeScript type definitions - app/logs/log-filters.tsx: Advanced filter controls with date picker - app/logs/log-entry-card.tsx: Individual log entry display - app/logs/log-details-dialog.tsx: Detailed log entry modal - components/ui/calendar.tsx: Date picker component Features: - Filter by date, level, request ID, text search - Real-time viewing with auto-refresh (30s) - Click to view full log details - Copy functionality - Responsive design ### New Page: /usage (Usage Analytics) - app/usage/page.tsx: Main analytics dashboard - components/usage-metrics-chart.tsx: Time-series line charts - components/usage-summary-cards.tsx: Summary statistics cards - components/error-details-table.tsx: Error details table - components/revenue-by-model-table.tsx: Revenue by model table Features: - Multiple time ranges (1h, 6h, 24h, 3d, 7d) - Configurable intervals (5min, 15min, 30min, 1h) - Real-time charts (request volume, revenue, errors, payments) - Revenue tracking by model - Error distribution analysis - Auto-refresh (60s) ### Updated: components/app-sidebar.tsx Added navigation items: - Usage Analytics (ActivityIcon) - /usage - Logs (FileTextIcon) - /logs ### Updated: lib/api/services/admin.ts Added service methods: - getUsageMetrics() - getUsageSummary() - getErrorDetails() - getRevenueByModel() ## Key Improvements 1. **Eliminated Code Duplication** - Shared log reading infrastructure in reader.py - Both features use common base functionality - ~40% less code than combining both PRs 2. **Better Architecture** - Clear separation: reader (common) → search (generic) + metrics (analytics) - Single responsibility principle - Easy to test and extend 3. **Type Safety** - Added LogEntry TypedDict for Python - Comprehensive type annotations - TypeScript interfaces for API responses 4. **Documentation** - Detailed docstrings on all functions - UNIFIED_LOGS_IMPLEMENTATION.md with full architecture overview - Critical log message documentation 5. **Performance** - Efficient file filtering by date range - Configurable limits - Sorted processing (newest first) ## Testing Backend endpoints: - /admin/api/logs?level=ERROR&limit=50 - /admin/api/usage/summary?hours=24 - /admin/api/usage/metrics?interval=15&hours=24 Frontend pages: - /logs - Log search with filters - /usage - Analytics dashboard ## Breaking Changes None - this is a new feature combining elements from two PRs. Co-authored-by: PR #228 contributors Co-authored-by: PR #229 contributors
206 lines
6.9 KiB
TypeScript
206 lines
6.9 KiB
TypeScript
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Copy, Check } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
interface LogEntry {
|
|
asctime: string;
|
|
name: string;
|
|
levelname: string;
|
|
message: string;
|
|
pathname: string;
|
|
lineno: number;
|
|
version: string;
|
|
request_id: string;
|
|
[key: string]: string | number | object | undefined;
|
|
}
|
|
|
|
interface LogDetailsDialogProps {
|
|
log: LogEntry | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const getLevelColor = (level: string): string => {
|
|
switch (level.toUpperCase()) {
|
|
case 'TRACE':
|
|
case 'DEBUG':
|
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
case 'INFO':
|
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
case 'WARNING':
|
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
case 'ERROR':
|
|
return 'bg-red-100 text-red-800 border-red-200';
|
|
case 'CRITICAL':
|
|
return 'bg-purple-100 text-purple-800 border-purple-200';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
}
|
|
};
|
|
|
|
export function LogDetailsDialog({
|
|
log,
|
|
isOpen,
|
|
onClose,
|
|
}: LogDetailsDialogProps) {
|
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
|
|
|
if (!log) return null;
|
|
|
|
const copyToClipboard = (text: string, fieldName?: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
if (fieldName) {
|
|
setCopiedField(fieldName);
|
|
setTimeout(() => setCopiedField(null), 2000);
|
|
}
|
|
};
|
|
|
|
const allFields = Object.keys(log).filter((key) => key !== 'key');
|
|
const standardFields = [
|
|
'asctime',
|
|
'name',
|
|
'levelname',
|
|
'message',
|
|
'pathname',
|
|
'lineno',
|
|
'version',
|
|
'request_id',
|
|
];
|
|
const extraFields = allFields.filter((key) => !standardFields.includes(key));
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
|
|
<DialogHeader>
|
|
<DialogTitle className='flex items-center gap-2'>
|
|
<Badge variant='outline' className={getLevelColor(log.levelname)}>
|
|
{log.levelname}
|
|
</Badge>
|
|
<span>Log Entry Details</span>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{log.asctime} • {log.name} • {log.pathname}:{log.lineno}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
|
|
<div className='space-y-6'>
|
|
<div>
|
|
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
|
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
|
|
<pre className='font-mono text-sm whitespace-pre break-all'>
|
|
{log.message}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className='mb-3 text-sm font-medium'>Standard Fields</h4>
|
|
<div className='grid grid-cols-1 gap-3'>
|
|
{standardFields.map((field) => (
|
|
<div key={field} className='flex flex-col space-y-1'>
|
|
<div className='flex items-center justify-between gap-2'>
|
|
<span className='text-muted-foreground text-xs font-medium uppercase'>
|
|
{field}
|
|
</span>
|
|
{field === 'request_id' && (
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => copyToClipboard(String(log[field as keyof LogEntry] || ''), field)}
|
|
className='h-6 flex-shrink-0 px-2'
|
|
>
|
|
{copiedField === field ? (
|
|
<>
|
|
<Check className='mr-1 h-3 w-3' />
|
|
Copied
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className='mr-1 h-3 w-3' />
|
|
Copy
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className='bg-muted max-h-32 overflow-auto rounded p-2'>
|
|
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
|
{String(log[field as keyof LogEntry] || 'N/A')}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{extraFields.length > 0 && (
|
|
<div>
|
|
<h4 className='mb-3 text-sm font-medium'>Additional Fields</h4>
|
|
<div className='grid grid-cols-1 gap-3'>
|
|
{extraFields.map((field) => (
|
|
<div key={field} className='flex flex-col space-y-1'>
|
|
<span className='text-muted-foreground truncate text-xs font-medium uppercase'>
|
|
{field}
|
|
</span>
|
|
<div className='bg-muted max-h-48 overflow-auto rounded p-2'>
|
|
{typeof log[field] === 'object' ? (
|
|
<pre className='font-mono text-xs break-all whitespace-pre-wrap'>
|
|
{JSON.stringify(log[field], null, 2)}
|
|
</pre>
|
|
) : (
|
|
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
|
{String(log[field] || 'N/A')}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<div className='mb-3 flex items-center justify-between'>
|
|
<h4 className='text-sm font-medium'>Raw JSON</h4>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => copyToClipboard(JSON.stringify(log, null, 2), 'json')}
|
|
className='h-6 px-2'
|
|
>
|
|
{copiedField === 'json' ? (
|
|
<>
|
|
<Check className='mr-1 h-3 w-3' />
|
|
Copied
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className='mr-1 h-3 w-3' />
|
|
Copy
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<div className='bg-muted max-h-64 overflow-auto rounded-md p-4'>
|
|
<pre className='text-xs break-all whitespace-pre-wrap'>
|
|
{JSON.stringify(log, null, 2)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|