diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 231a4ba..a20a991 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,9 +25,11 @@ "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^17.6.0", "postcss": "^8.4.49", "tailwindcss": "^4.0.0", "typescript": "~5.6.0", + "typescript-eslint": "^8.60.0", "vite": "^6.0.0" } }, @@ -822,6 +824,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -1658,6 +1672,285 @@ "@types/react": "^19.2.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2528,9 +2821,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "engines": { "node": ">=18" @@ -3504,6 +3797,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3529,6 +3834,29 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9976919..17fbd8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,9 +27,11 @@ "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^17.6.0", "postcss": "^8.4.49", "tailwindcss": "^4.0.0", "typescript": "~5.6.0", + "typescript-eslint": "^8.60.0", "vite": "^6.0.0" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d755456..605b41b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,25 +2,35 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter, Routes, Route } from 'react-router-dom' import Dashboard from './pages/Dashboard' 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..66eaa71 --- /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: Record) => + 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]) +} diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 2728f65..4dd5ba3 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -35,7 +35,7 @@ export default function InventoryPage() { queryFn: () => fetchFilaments(filter), }) - const filaments = data?.data ?? [] + const filaments = useMemo(() => data?.data ?? [], [data?.data]) const total = data?.total ?? 0 const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))