diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5a8c3c..deb3cd0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,24 +1,34 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter, Routes, Route } from 'react-router-dom' import InventoryPage from './pages/InventoryPage' +import ToastContainer from './components/ToastContainer' +import { useSSE } from './hooks/useSSE' const queryClient = new QueryClient() +function SSEProvider({ children }: { children: React.ReactNode }) { + useSSE() + return <>{children} +} + export default function App() { return ( -
-
-
E
-

Extrudex

-
-
- - } /> - -
-
+ +
+
+
E
+

Extrudex

+
+
+ + } /> + +
+
+ +
) diff --git a/frontend/src/components/PrinterStatusBadge.tsx b/frontend/src/components/PrinterStatusBadge.tsx new file mode 100644 index 0000000..ebd8931 --- /dev/null +++ b/frontend/src/components/PrinterStatusBadge.tsx @@ -0,0 +1,17 @@ +import { Printer } from 'lucide-react' + +const STYLES = { + online: 'bg-emerald-600 text-white', + offline: 'bg-slate-600 text-slate-200', + printing: 'bg-blue-600 text-white animate-pulse', +} + +export default function PrinterStatusBadge({ status, name }: { status: string; name?: string }) { + const style = STYLES[status as keyof typeof STYLES] ?? STYLES.offline + return ( + + + {name ? `${name} · ${status}` : status} + + ) +} diff --git a/frontend/src/components/ToastContainer.tsx b/frontend/src/components/ToastContainer.tsx new file mode 100644 index 0000000..b566e6d --- /dev/null +++ b/frontend/src/components/ToastContainer.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState, useCallback } from 'react' +import { X, CheckCircle, AlertTriangle, Info } from 'lucide-react' +import { addToastListener, type ToastMessage } from '../hooks/useSSE' + +const ICONS = { + info: Info, + success: CheckCircle, + warning: AlertTriangle, +} + +const STYLES = { + info: 'border-blue-600 bg-blue-900/40 text-blue-100', + success: 'border-emerald-600 bg-emerald-900/40 text-emerald-100', + warning: 'border-amber-600 bg-amber-900/40 text-amber-100', +} + +export default function ToastContainer() { + const [toasts, setToasts] = useState([]) + + const remove = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + useEffect(() => { + return addToastListener((toast) => { + setToasts(prev => { + // Prevent duplicate identical toasts within 3 seconds + const now = Date.now() + const recent = prev.filter( + t => t.title === toast.title && now - parseInt(t.id.split('-').pop() || '0', 10) < 3000 + ) + if (recent.length > 0) return prev + return [...prev.slice(-4), toast] + }) + // Auto-dismiss after 5 seconds + setTimeout(() => remove(toast.id), 5000) + }) + }, [remove]) + + return ( +
+ {toasts.map((toast) => { + const Icon = ICONS[toast.type] + return ( +
+
+ +
+

{toast.title}

+ {toast.body && ( +

{toast.body}

+ )} +
+ +
+
+ ) + })} +
+ ) +} diff --git a/frontend/src/hooks/.gitkeep b/frontend/src/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts new file mode 100644 index 0000000..051f95d --- /dev/null +++ b/frontend/src/hooks/useSSE.ts @@ -0,0 +1,193 @@ +import { useEffect, useRef, useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' + +export type SSEEventType = + | 'printer.status' + | 'job.started' + | 'job.completed' + | 'filament.low' + +export interface SSEEvent { + type: SSEEventType + payload: unknown + timestamp: string +} + +export interface ToastMessage { + id: string + type: 'info' | 'success' | 'warning' + title: string + body?: string +} + +let toastListeners: Array<(toast: ToastMessage) => void> = [] + +export function addToastListener(cb: (toast: ToastMessage) => void) { + toastListeners.push(cb) + return () => { + toastListeners = toastListeners.filter(l => l !== cb) + } +} + +function emitToast(toast: ToastMessage) { + toastListeners.forEach(cb => cb(toast)) +} + +const RECONNECT_BASE_MS = 1000 +const RECONNECT_MAX_MS = 30000 + +export function useSSE(enabled = true) { + const queryClient = useQueryClient() + const esRef = useRef(null) + const reconnectTimerRef = useRef | null>(null) + const attemptRef = useRef(0) + + const connect = useCallback(() => { + if (!enabled) return + if (esRef.current) { + esRef.current.close() + } + + const es = new EventSource('/api/events') + esRef.current = es + + es.onopen = () => { + attemptRef.current = 0 + } + + es.addEventListener('printer.status', (e) => { + try { + const data = JSON.parse(e.data) as SSEEvent + const payload = data.payload as { + printer_id: number + printer_name: string + status: string + } + // Update printer list cache if it exists + queryClient.setQueryData(['printers'], (old: unknown) => { + if (!Array.isArray(old)) return old + return old.map((p: any) => + p.id === payload.printer_id + ? { ...p, status: payload.status, name: payload.printer_name } + : p + ) + }) + } catch { + // ignore malformed event + } + }) + + es.addEventListener('job.started', (e) => { + try { + const data = JSON.parse(e.data) as SSEEvent + const payload = data.payload as { + job_id: number + job_name: string + printer_id: number + spool_id?: number + } + emitToast({ + id: `job-started-${payload.job_id}-${Date.now()}`, + type: 'info', + title: `Job started: ${payload.job_name}`, + body: `Printer #${payload.printer_id}`, + }) + // Invalidate print jobs list + queryClient.invalidateQueries({ queryKey: ['print-jobs'] }) + } catch { + // ignore malformed event + } + }) + + es.addEventListener('job.completed', (e) => { + try { + const data = JSON.parse(e.data) as SSEEvent + const payload = data.payload as { + job_id: number + job_name: string + printer_id: number + duration_seconds?: number + total_grams_used?: number + total_cost_usd?: number + } + const parts: string[] = [] + if (payload.duration_seconds != null) { + const mins = Math.round(payload.duration_seconds / 60) + parts.push(`${mins} min`) + } + if (payload.total_grams_used != null) { + parts.push(`${payload.total_grams_used.toFixed(1)} g used`) + } + emitToast({ + id: `job-completed-${payload.job_id}-${Date.now()}`, + type: 'success', + title: `Job completed: ${payload.job_name}`, + body: parts.join(' · ') || `Printer #${payload.printer_id}`, + }) + queryClient.invalidateQueries({ queryKey: ['print-jobs'] }) + queryClient.invalidateQueries({ queryKey: ['filaments'] }) + } catch { + // ignore malformed event + } + }) + + es.addEventListener('filament.low', (e) => { + try { + const data = JSON.parse(e.data) as SSEEvent + const payload = data.payload as { + spool_id: number + spool_name: string + remaining_grams: number + threshold_grams: number + } + emitToast({ + id: `filament-low-${payload.spool_id}-${Date.now()}`, + type: 'warning', + title: `Low filament: ${payload.spool_name}`, + body: `${payload.remaining_grams} g remaining (threshold ${payload.threshold_grams} g)`, + }) + // Update filament inventory cache in place for instant UI feedback + queryClient.setQueryData(['filaments'], (old: unknown) => { + if (!old || typeof old !== 'object') return old + const o = old as { data?: Array>; total?: number } + if (!Array.isArray(o.data)) return old + return { + ...o, + data: o.data.map((f) => + (f.id as number) === payload.spool_id + ? { ...f, remaining_grams: payload.remaining_grams } + : f + ), + } + }) + } catch { + // ignore malformed event + } + }) + + es.onerror = () => { + es.close() + // Exponential backoff with jitter + const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attemptRef.current) + const jitter = Math.random() * 1000 + const delay = base + jitter + attemptRef.current += 1 + reconnectTimerRef.current = setTimeout(() => { + connect() + }, delay) + } + }, [enabled, queryClient]) + + useEffect(() => { + connect() + return () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + } + if (esRef.current) { + esRef.current.close() + esRef.current = null + } + } + }, [connect]) +}