bfc0050580
Phase L: Dashboard widget system - Migration 046: dashboard_configs table (user/tenant/role fallback cascade) - DashboardConfig model + dashboard_service with get/upsert per-user and tenant-default - API router: GET/PUT /api/dashboard/config, GET/PUT /api/dashboard/tenant-default - Frontend: 5 widget components (ProductionStats, QueueStatus, RecentRenders, CostOverview, WorkerStatus) - DashboardGrid with API-backed config, DashboardCustomizeModal (checkbox selection, save/cancel) - Dashboard.tsx: widget grid + analytics section (privileged users) - Admin.tsx: restructured with new section order and Maintenance 2-col grid Phase M: Test framework - Backend: pytest-asyncio + pytest-cov + factory-boy in pyproject.toml - conftest.py: excel_dir fixtures + async DB fixtures + mock storage/celery stubs - Domain tests: billing_service, cache_service, notifications_service, imports_sanity - Integration: test_api_health.py smoke test (requires running backend) - Frontend: vitest + @testing-library/react + msw added to package.json - vite.config.ts: test block (jsdom + globals + setupFiles) - tsconfig.json: exclude src/__tests__ from main tsc (test runner handles its own types) - MSW handlers for /api/auth/me, Billing.test.tsx, formatters.test.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { Terminal, ChevronDown, ChevronUp } from 'lucide-react'
|
|
import { getRenderLog } from '../api/worker'
|
|
import type { RenderLogEntry } from '../api/worker'
|
|
|
|
const LEVEL_COLORS: Record<string, string> = {
|
|
info: 'text-gray-300',
|
|
error: 'text-red-400',
|
|
success: 'text-green-400',
|
|
warn: 'text-yellow-400',
|
|
}
|
|
|
|
/**
|
|
* Live render log panel — polls Redis-backed log entries every 2s.
|
|
* Shows a compact terminal-style output for a render job.
|
|
*
|
|
* Always does an initial fetch to check if entries exist (so failed jobs
|
|
* still show their log). Polls only when isActive.
|
|
*/
|
|
export default function LiveRenderLog({
|
|
orderLineId,
|
|
isActive,
|
|
compact = false,
|
|
}: {
|
|
orderLineId: string
|
|
/** Whether the render is still processing — enables polling */
|
|
isActive: boolean
|
|
/** Compact mode (inline, no border) for table rows */
|
|
compact?: boolean
|
|
}) {
|
|
const [expanded, setExpanded] = useState(isActive)
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Always fetch once to probe for existing entries; poll only when active
|
|
const { data } = useQuery({
|
|
queryKey: ['render-log', orderLineId],
|
|
queryFn: () => getRenderLog(orderLineId),
|
|
refetchInterval: isActive ? 2000 : false,
|
|
})
|
|
|
|
const entries: RenderLogEntry[] = data?.entries ?? []
|
|
const hasEntries = entries.length > 0
|
|
|
|
// Auto-scroll to bottom when new entries arrive
|
|
useEffect(() => {
|
|
if (scrollRef.current && isActive) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
}
|
|
}, [entries.length, isActive])
|
|
|
|
// Auto-expand when active
|
|
useEffect(() => {
|
|
if (isActive) setExpanded(true)
|
|
}, [isActive])
|
|
|
|
// Nothing to show at all
|
|
if (!hasEntries && !isActive) return null
|
|
|
|
if (compact) {
|
|
return (
|
|
<div className="mt-1">
|
|
<button
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="text-[10px] text-gray-400 hover:text-gray-600 flex items-center gap-1"
|
|
>
|
|
<Terminal size={10} />
|
|
Log ({entries.length})
|
|
{expanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
|
</button>
|
|
{expanded && hasEntries && (
|
|
<LogPanel entries={entries} isActive={isActive} scrollRef={scrollRef} maxHeight="120px" />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="mt-2">
|
|
<button
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 mb-1"
|
|
>
|
|
<Terminal size={12} />
|
|
<span className="font-medium">Render Log</span>
|
|
<span className="text-gray-400">({entries.length} entries)</span>
|
|
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
</button>
|
|
{expanded && (
|
|
<LogPanel entries={entries} isActive={isActive} scrollRef={scrollRef} maxHeight="200px" />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LogPanel({
|
|
entries,
|
|
isActive,
|
|
scrollRef,
|
|
maxHeight,
|
|
}: {
|
|
entries: RenderLogEntry[]
|
|
isActive: boolean
|
|
scrollRef: React.RefObject<HTMLDivElement>
|
|
maxHeight: string
|
|
}) {
|
|
return (
|
|
<div
|
|
ref={scrollRef}
|
|
className="bg-gray-900 rounded-md p-2 overflow-y-auto font-mono text-[11px] leading-relaxed"
|
|
style={{ maxHeight }}
|
|
>
|
|
{entries.map((entry, i) => (
|
|
<div key={i} className={`flex gap-2 ${LEVEL_COLORS[entry.level] || LEVEL_COLORS.info}`}>
|
|
<span className="text-gray-500 shrink-0 select-none">{entry.t}</span>
|
|
<span>{entry.msg}</span>
|
|
</div>
|
|
))}
|
|
{isActive && entries.length > 0 && (
|
|
<div className="text-gray-500 animate-pulse">...</div>
|
|
)}
|
|
{entries.length === 0 && (
|
|
<div className="text-gray-600 italic">No log entries yet</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|