Browse Source

Fix SpoolBuddy add-to-inventory, dashboard crash, and kiosk display issues

  - Round scale weight to integer before sending to backend (Pydantic
    rejects non-whole floats for int fields), move modal close to finally
    block, add error toast with actual API message
  - Fix null-field crash in SpoolInfoCard prop construction: pick one
    source object instead of per-field ?? fallbacks that crash when
    displayedSpool has null subtype/brand/rgba and matchedSpool is null
  - Add React ErrorBoundary to App so crashes show error instead of
    black screen
  - Remove --max-old-space-size=128 and --enable-low-end-device-mode
    from kiosk Chromium flags (crashed renderer/display)
  - Append kiosk flags to Pi GPU defaults instead of resetting them
  - Add wlr-randr keep-alive and screenBlankTimeout=0 to prevent
    display blanking on labwc 0.9.x
  - Fix tests: add ToastProvider to Dashboard test wrapper, update
    StatusBar tests for removed animate-pulse class
maziggy 2 tháng trước cách đây
mục cha
commit
a4b927f2d2

+ 4 - 1
CHANGELOG.md

@@ -6,6 +6,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### Fixed
 - **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. Reported by @NoahTingey.
+- **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.
+- **SpoolBuddy Dashboard Crash on Null Spool Fields** — Viewing a spool with null `subtype`, `brand`, `rgba`, or `color_name` on the SpoolBuddy dashboard crashed the UI (black screen). The spool prop construction used `displayedSpool?.subtype ?? sbState.matchedSpool!.subtype` — when the field was `null`, the `??` operator fell through to `sbState.matchedSpool` which could also be null, causing a TypeError. Fixed by picking one source object instead of mixing per-field fallbacks. Added a global React error boundary so future crashes show the error instead of a black screen.
+- **SpoolBuddy Kiosk Display Blanking and Crashes** — The kiosk Chromium flags added in 0.2.2.2 caused display instability: `--js-flags=--max-old-space-size=128` crashed the V8 renderer when heap exceeded 128 MB, `--enable-low-end-device-mode` aggressively killed GPU rendering surfaces, and resetting `CHROMIUM_FLAGS` discarded the Pi's GPU defaults (`--enable-gpu-rasterization`, ANGLE/GLES) creating an unstable mixed CPU/GPU rendering path. Fixed by removing both flags, appending kiosk flags to Pi defaults instead of replacing them, adding a `wlr-randr` keep-alive loop to prevent display blanking, and adding `<screenBlankTimeout>0</screenBlankTimeout>` to the labwc config.
 
 ## [0.2.2.2] - 2026-03-27
 
@@ -17,7 +20,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, gcode, and image files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.
 
 ### Improved
-- **SpoolBuddy Kiosk Performance Optimizations** — Reduced idle CPU load on Raspberry Pi from ~3.3 to ~0.9. Frontend: replaced expensive CSS animations on the idle dashboard (`animate-ping` with scale transforms, `blur-2xl` glow, continuous `animate-pulse` on status dots) with static elements and a slow color-cycling spool (5s interval). Chromium: added `--disable-extensions`, `--disable-background-timer-throttling`, `--memory-pressure-off`, `--disable-renderer-backgrounding`, `--disable-breakpad`, and `--js-flags=--max-old-space-size=128` to `/etc/chromium.d/spoolbuddy-kiosk`. WebSocket: SpoolBuddy Dashboard and Layout pages now use React Query `select` to extract only `connected` status from printer queries, so temperature/fan/progress updates no longer trigger re-renders on every MQTT tick. Services: stripped services are now masked (not just disabled) to prevent socket/dbus reactivation; user-level services (xdg-desktop-portal, mpris-proxy, pipewire, etc.) are masked globally via `/etc/systemd/user/` overrides instead of unreliable `su -l systemctl --user`. Removed chromium and upower from `strip_packages` since the kiosk needs them — they were being uninstalled then immediately reinstalled on every run.
+- **SpoolBuddy Kiosk Performance Optimizations** — Reduced idle CPU load on Raspberry Pi from ~3.3 to ~0.9. Frontend: replaced expensive CSS animations on the idle dashboard (`animate-ping` with scale transforms, `blur-2xl` glow, continuous `animate-pulse` on status dots) with static elements and a slow color-cycling spool (5s interval). Chromium: added `--disable-extensions`, `--disable-background-timer-throttling`, `--disable-renderer-backgrounding`, and `--disable-crash-reporter` to `/etc/chromium.d/spoolbuddy-kiosk`. WebSocket: SpoolBuddy Dashboard and Layout pages now use React Query `select` to extract only `connected` status from printer queries, so temperature/fan/progress updates no longer trigger re-renders on every MQTT tick. Services: stripped services are now masked (not just disabled) to prevent socket/dbus reactivation; user-level services (xdg-desktop-portal, mpris-proxy, pipewire, etc.) are masked globally via `/etc/systemd/user/` overrides instead of unreliable `su -l systemctl --user`. Removed chromium and upower from `strip_packages` since the kiosk needs them — they were being uninstalled then immediately reinstalled on every run.
 - **SpoolBuddy AMS Slot Action Picker** — Clicking an AMS slot on the SpoolBuddy AMS page now shows a picker with contextual actions: Configure AMS Slot (set filament preset, K-profile, color), and either Assign Spool / Link to Spoolman (when no spool is mapped) or Unassign / Unlink (when one is). Works with both internal inventory and Spoolman. Previously the slot click went straight to the configure modal with no way to manage spool assignments.
 - **Unassign Button in Edit Spool Modal** — The edit spool modal now has an "Unassign" button next to "Delete Tag" that removes the spool's AMS slot assignment, clearing the location column in the inventory table.
 - **SpoolBuddy Settings Device Tab No Longer Scrolls** — Removed the branding card, folded Device ID into the Device Info card, placed Backend/Auth config and diagnostic buttons side by side in a 2-column layout, removed the redundant online/offline status row from Device Info, and tightened spacing throughout. The Device tab now fits on the small SpoolBuddy touchscreen without scrolling.

+ 37 - 0
frontend/src/App.tsx

@@ -1,3 +1,4 @@
+import { Component, type ReactNode, type ErrorInfo } from 'react';
 import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { Layout } from './components/Layout';
@@ -32,6 +33,40 @@ import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPag
 import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
 import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
 import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
+class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null; errorInfo: ErrorInfo | null }> {
+  state = { error: null as Error | null, errorInfo: null as ErrorInfo | null };
+
+  static getDerivedStateFromError(error: Error) {
+    return { error };
+  }
+
+  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+    this.setState({ errorInfo });
+    console.error('React crash:', error, errorInfo);
+  }
+
+  render() {
+    if (this.state.error) {
+      return (
+        <div style={{ padding: 24, color: '#ef4444', backgroundColor: '#18181b', minHeight: '100vh', fontFamily: 'monospace' }}>
+          <h1 style={{ fontSize: 20, marginBottom: 12 }}>UI Crash</h1>
+          <pre style={{ whiteSpace: 'pre-wrap', fontSize: 14 }}>{this.state.error.message}</pre>
+          <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, color: '#a1a1aa', marginTop: 12 }}>
+            {this.state.error.stack}
+          </pre>
+          <button
+            onClick={() => { this.setState({ error: null, errorInfo: null }); }}
+            style={{ marginTop: 16, padding: '8px 16px', backgroundColor: '#3b82f6', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}
+          >
+            Retry
+          </button>
+        </div>
+      );
+    }
+    return this.props.children;
+  }
+}
+
 const queryClient = new QueryClient({
   defaultOptions: {
     queries: {
@@ -109,6 +144,7 @@ function SetupRoute({ children }: { children: React.ReactNode }) {
 
 function App() {
   return (
+    <ErrorBoundary>
     <ThemeProvider>
       <ToastProvider>
         <QueryClientProvider client={queryClient}>
@@ -165,6 +201,7 @@ function App() {
         </QueryClientProvider>
       </ToastProvider>
     </ThemeProvider>
+    </ErrorBoundary>
   );
 }
 

+ 4 - 4
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyStatusBar.test.tsx

@@ -25,7 +25,7 @@ describe('SpoolBuddyStatusBar', () => {
 
   it('uses green status LED when no alert', () => {
     const { container } = render(<SpoolBuddyStatusBar />);
-    const led = container.querySelector('.rounded-full.animate-pulse');
+    const led = container.querySelector('.rounded-full');
     expect(led!.className).toContain('bg-bambu-green');
   });
 
@@ -34,7 +34,7 @@ describe('SpoolBuddyStatusBar', () => {
       <SpoolBuddyStatusBar alert={{ type: 'warning', message: 'Low filament' }} />
     );
     expect(screen.getByText('Low filament')).toBeDefined();
-    const led = container.querySelector('.rounded-full.animate-pulse');
+    const led = container.querySelector('.rounded-full');
     expect(led!.className).toContain('bg-amber-500');
     // Border should also be amber
     const bar = container.firstElementChild as HTMLElement;
@@ -46,7 +46,7 @@ describe('SpoolBuddyStatusBar', () => {
       <SpoolBuddyStatusBar alert={{ type: 'error', message: 'Connection lost' }} />
     );
     expect(screen.getByText('Connection lost')).toBeDefined();
-    const led = container.querySelector('.rounded-full.animate-pulse');
+    const led = container.querySelector('.rounded-full');
     expect(led!.className).toContain('bg-red-500');
     const bar = container.firstElementChild as HTMLElement;
     expect(bar.className).toContain('border-red-500');
@@ -57,7 +57,7 @@ describe('SpoolBuddyStatusBar', () => {
       <SpoolBuddyStatusBar alert={{ type: 'info', message: 'Update available' }} />
     );
     expect(screen.getByText('Update available')).toBeDefined();
-    const led = container.querySelector('.rounded-full.animate-pulse');
+    const led = container.querySelector('.rounded-full');
     expect(led!.className).toContain('bg-bambu-green');
   });
 });

+ 12 - 9
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -13,6 +13,7 @@ import { render } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
 import { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';
+import { ToastProvider } from '../../contexts/ToastContext';
 
 vi.mock('../../api/client', () => ({
   api: {
@@ -69,15 +70,17 @@ function renderPage(overrides: Partial<typeof mockOutletContext['sbState']> = {}
   }
   const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
   return render(
-    <QueryClientProvider client={qc}>
-      <MemoryRouter initialEntries={['/spoolbuddy']}>
-        <Routes>
-          <Route element={<Wrapper />}>
-            <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
-          </Route>
-        </Routes>
-      </MemoryRouter>
-    </QueryClientProvider>
+    <ToastProvider>
+      <QueryClientProvider client={qc}>
+        <MemoryRouter initialEntries={['/spoolbuddy']}>
+          <Routes>
+            <Route element={<Wrapper />}>
+              <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+            </Route>
+          </Routes>
+        </MemoryRouter>
+      </QueryClientProvider>
+    </ToastProvider>
   );
 }
 

+ 23 - 16
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -4,6 +4,7 @@ import { useQuery, useQueries } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
+import { useToast } from '../../contexts/ToastContext';
 import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
 import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
@@ -117,6 +118,7 @@ function DeviceOfflineState() {
 export function SpoolBuddyDashboard() {
   const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
+  const { showToast } = useToast();
 
   // Fetch spools for stats, tag lookup, and untagged list
   const { data: spools = [], refetch: refetchSpools } = useQuery({
@@ -253,15 +255,17 @@ export function SpoolBuddyDashboard() {
         data_origin: 'spoolbuddy',
         tag_type: 'generic',
         cost_per_kg: null,
-        last_scale_weight: weight,
+        last_scale_weight: weight !== null ? Math.round(weight) : null,
         last_weighed_at: weight !== null ? new Date().toISOString() : null,
       });
-      setShowQuickAddModal(false);
-      refetchSpools();
     } catch (e) {
-      console.error('Failed to quick-add spool:', e);
+      const msg = e instanceof Error ? e.message : String(e);
+      console.error('Failed to quick-add spool:', msg);
+      showToast(msg || t('spoolbuddy.errors.quickAddFailed', 'Failed to add spool'), 'error');
     } finally {
+      setShowQuickAddModal(false);
       setQuickAddBusy(false);
+      refetchSpools();
     }
   };
 
@@ -378,18 +382,21 @@ export function SpoolBuddyDashboard() {
                 <DeviceOfflineState />
               ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
                 <SpoolInfoCard
-                  spool={{
-                    id: displayedSpool?.id ?? sbState.matchedSpool!.id,
-                    tag_uid: displayedTagId,
-                    material: displayedSpool?.material ?? sbState.matchedSpool!.material,
-                    subtype: displayedSpool?.subtype ?? sbState.matchedSpool!.subtype,
-                    color_name: displayedSpool?.color_name ?? sbState.matchedSpool!.color_name,
-                    rgba: displayedSpool?.rgba ?? sbState.matchedSpool!.rgba,
-                    brand: displayedSpool?.brand ?? sbState.matchedSpool!.brand,
-                    label_weight: displayedSpool?.label_weight ?? sbState.matchedSpool!.label_weight,
-                    core_weight: displayedSpool?.core_weight ?? sbState.matchedSpool!.core_weight,
-                    weight_used: displayedSpool?.weight_used ?? sbState.matchedSpool!.weight_used,
-                  }}
+                  spool={(() => {
+                    const s = displayedSpool ?? sbState.matchedSpool!;
+                    return {
+                      id: s.id,
+                      tag_uid: displayedTagId,
+                      material: s.material,
+                      subtype: s.subtype,
+                      color_name: s.color_name,
+                      rgba: s.rgba,
+                      brand: s.brand,
+                      label_weight: s.label_weight,
+                      core_weight: s.core_weight,
+                      weight_used: s.weight_used,
+                    };
+                  })()}
                   scaleWeight={liveWeight ?? displayedWeight}
                   onSyncWeight={() => refetchSpools()}
                   onAssignToAms={() => setShowAssignAmsModal(true)}

+ 10 - 11
spoolbuddy/install/install.sh

@@ -1079,6 +1079,11 @@ EOF
 <?xml version="1.0"?>
 <labwc_config>
 
+  <!-- Disable screen blanking — kiosk must stay on -->
+  <core>
+    <screenBlankTimeout>0</screenBlankTimeout>
+  </core>
+
   <theme>
     <name></name>
     <cornerRadius>0</cornerRadius>
@@ -1109,22 +1114,13 @@ EOF
 
         # ── Override Debian/RPi Chromium defaults for kiosk performance ──────
         cat > /etc/chromium.d/spoolbuddy-kiosk << 'CHROMIUM_EOF'
-# SpoolBuddy kiosk: override system defaults for low-end Pi hardware.
-# Replaces CHROMIUM_FLAGS entirely — system defaults (gpu-rasterization,
-# remote-extensions, pings, media-router) are not needed in kiosk mode.
-CHROMIUM_FLAGS="--disable-gpu-rasterization"
+# SpoolBuddy kiosk: add kiosk-specific flags on top of Pi defaults.
+# Preserves Pi GPU settings (gpu-rasterization, ANGLE/GLES) for stability.
 CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-smooth-scrolling"
-CHROMIUM_FLAGS="$CHROMIUM_FLAGS --enable-low-end-device-mode"
-CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-background-networking"
-CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-dev-shm-usage"
-CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-pings"
-CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-default-browser-check"
 CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-extensions"
 CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-background-timer-throttling"
-CHROMIUM_FLAGS="$CHROMIUM_FLAGS --memory-pressure-off"
 CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-renderer-backgrounding"
 CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-crash-reporter"
-CHROMIUM_FLAGS="$CHROMIUM_FLAGS --js-flags=--max-old-space-size=128"
 CHROMIUM_EOF
         success "Chromium kiosk performance flags installed"
 
@@ -1182,6 +1178,9 @@ EOF
 # Force 1024x600 (panel doesn't advertise this natively)
 wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
 
+# Prevent display blanking (labwc 0.9.x has no config option for this)
+(while true; do sleep 60; wlr-randr --output HDMI-A-1 --on 2>/dev/null; done) &
+
 # Launch Chromium via helper that resolves URL from spoolbuddy/.env
 $kiosk_launcher &
 EOF

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-CdG7loGy.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CnzPkDwq.js"></script>
+    <script type="module" crossorigin src="/assets/index-CdG7loGy.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C0h3VoP7.css">
   </head>
   <body>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác