Sfoglia il codice sorgente

Updated .github/workflows/cleanup-ghcr.yml

maziggy 2 mesi fa
parent
commit
b73c21f442

+ 1 - 1
.github/workflows/cleanup-ghcr.yml

@@ -31,7 +31,7 @@ jobs:
     strategy:
     strategy:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
-        package: [bambuddy, bambuddy-beta]
+        package: [bambuddy]
     steps:
     steps:
       - name: Cleanup ${{ matrix.package }}
       - name: Cleanup ${{ matrix.package }}
         env:
         env:

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 105 - 0
frontend/src/__tests__/hooks/useDispatchedPrinterIds.test.ts

@@ -0,0 +1,105 @@
+/**
+ * Tests for useDispatchedPrinterIds — the hook that exposes printer IDs with
+ * a queued/active background-dispatch job so PrinterSelector can grey them
+ * out between dispatch-accepted and the printer's PRINT_START report.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import {
+  useDispatchedPrinterIds,
+  __resetDispatchedPrinterIdsForTests,
+} from '../../hooks/useDispatchedPrinterIds';
+
+function fire(detail: Record<string, unknown>) {
+  act(() => {
+    window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
+  });
+}
+
+describe('useDispatchedPrinterIds', () => {
+  beforeEach(() => {
+    __resetDispatchedPrinterIdsForTests();
+  });
+
+  afterEach(() => {
+    __resetDispatchedPrinterIdsForTests();
+  });
+
+  it('returns an empty set initially', () => {
+    const { result } = renderHook(() => useDispatchedPrinterIds());
+    expect(result.current.size).toBe(0);
+  });
+
+  it('picks up printer IDs from dispatched_jobs', () => {
+    const { result } = renderHook(() => useDispatchedPrinterIds());
+    fire({
+      dispatched_jobs: [
+        { job_id: 1, printer_id: 42, printer_name: 'Farm-A' },
+      ],
+      active_jobs: [],
+    });
+    expect(result.current.has(42)).toBe(true);
+    expect(result.current.size).toBe(1);
+  });
+
+  it('picks up printer IDs from active_jobs', () => {
+    const { result } = renderHook(() => useDispatchedPrinterIds());
+    fire({
+      dispatched_jobs: [],
+      active_jobs: [
+        { job_id: 1, printer_id: 7, printer_name: 'Farm-B' },
+      ],
+    });
+    expect(result.current.has(7)).toBe(true);
+  });
+
+  it('unions both lists', () => {
+    const { result } = renderHook(() => useDispatchedPrinterIds());
+    fire({
+      dispatched_jobs: [{ job_id: 1, printer_id: 1 }],
+      active_jobs: [{ job_id: 2, printer_id: 2 }],
+    });
+    expect(result.current.size).toBe(2);
+    expect(result.current.has(1)).toBe(true);
+    expect(result.current.has(2)).toBe(true);
+  });
+
+  it('clears printers when subsequent event reports no jobs', () => {
+    const { result } = renderHook(() => useDispatchedPrinterIds());
+    fire({ dispatched_jobs: [{ job_id: 1, printer_id: 9 }], active_jobs: [] });
+    expect(result.current.has(9)).toBe(true);
+    fire({ dispatched_jobs: [], active_jobs: [] });
+    expect(result.current.size).toBe(0);
+  });
+
+  it('ignores jobs without a numeric printer_id', () => {
+    const { result } = renderHook(() => useDispatchedPrinterIds());
+    fire({
+      dispatched_jobs: [
+        { job_id: 1, printer_id: 'not-a-number' },
+        { job_id: 2 },
+        { job_id: 3, printer_id: 5 },
+      ],
+      active_jobs: [],
+    });
+    expect(result.current.size).toBe(1);
+    expect(result.current.has(5)).toBe(true);
+  });
+
+  it('keeps snapshot reference stable when content is unchanged', () => {
+    const { result } = renderHook(() => useDispatchedPrinterIds());
+    fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] });
+    const first = result.current;
+    fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] });
+    expect(result.current).toBe(first);
+  });
+
+  it('shares state across hook instances', () => {
+    const a = renderHook(() => useDispatchedPrinterIds());
+    const b = renderHook(() => useDispatchedPrinterIds());
+    fire({ dispatched_jobs: [{ printer_id: 11 }], active_jobs: [] });
+    expect(a.result.current.has(11)).toBe(true);
+    expect(b.result.current.has(11)).toBe(true);
+  });
+});

+ 9 - 0
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -12,6 +12,7 @@ import {
   Users,
   Users,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api, type PrinterStatus } from '../../api/client';
 import { api, type PrinterStatus } from '../../api/client';
+import { useDispatchedPrinterIds } from '../../hooks/useDispatchedPrinterIds';
 import { getColorName } from '../../utils/colors';
 import { getColorName } from '../../utils/colors';
 import {
 import {
   normalizeColorForCompare,
   normalizeColorForCompare,
@@ -255,7 +256,14 @@ export function PrinterSelector({
     return map;
     return map;
   }, [activePrinters, statusQueries]);
   }, [activePrinters, statusQueries]);
 
 
+  // Printers with a queued/active background dispatch — accepted by Bambuddy
+  // but not yet reflected in PrinterStatus.state (which only flips on
+  // PRINT_START from the printer itself). Backend rejects double-sends with
+  // 409 anyway; this just stops the operator from picking them in the modal.
+  const dispatchedPrinterIds = useDispatchedPrinterIds();
+
   const isPrinterBusy = (printerId: number): boolean => {
   const isPrinterBusy = (printerId: number): boolean => {
+    if (dispatchedPrinterIds.has(printerId)) return true;
     const status = printerStatusMap.get(printerId);
     const status = printerStatusMap.get(printerId);
     if (!status) return false; // Unknown state — don't block
     if (!status) return false; // Unknown state — don't block
     if (!status.connected) return true;
     if (!status.connected) return true;
@@ -263,6 +271,7 @@ export function PrinterSelector({
   };
   };
 
 
   const getPrinterStateLabel = (printerId: number): string | null => {
   const getPrinterStateLabel = (printerId: number): string | null => {
+    if (dispatchedPrinterIds.has(printerId)) return 'Dispatching...';
     const status = printerStatusMap.get(printerId);
     const status = printerStatusMap.get(printerId);
     if (!status) return null;
     if (!status) return null;
     if (!status.connected) return 'Offline';
     if (!status.connected) return 'Offline';

+ 85 - 0
frontend/src/hooks/useDispatchedPrinterIds.ts

@@ -0,0 +1,85 @@
+/**
+ * Subscribes to background-dispatch WebSocket events and returns the set of
+ * printer IDs that currently have a queued or active dispatch job.
+ *
+ * Used by PrinterSelector to disable printers between the moment Bambuddy
+ * accepts a dispatch (FTP upload, print command) and the moment the printer
+ * itself reports PRINT_START. The backend already rejects double-sends with
+ * HTTP 409, but the UI gap still let operators pick a printer the server would
+ * refuse — surfaced by a corporate user running multi-operator farm shifts.
+ *
+ * Module-level state + useSyncExternalStore so every PrinterSelector instance
+ * sees the same snapshot, and component mounts mid-batch pick up the latest
+ * state without re-fetching.
+ */
+import { useSyncExternalStore } from 'react';
+
+interface DispatchEventJob {
+  printer_id?: unknown;
+}
+
+interface DispatchEventDetail {
+  dispatched_jobs?: DispatchEventJob[];
+  active_jobs?: DispatchEventJob[];
+  total?: number;
+  dispatched?: number;
+  processing?: number;
+}
+
+const EMPTY: ReadonlySet<number> = new Set();
+let currentSet: ReadonlySet<number> = EMPTY;
+const subscribers = new Set<() => void>();
+let attached = false;
+
+function recompute(detail: DispatchEventDetail): ReadonlySet<number> {
+  const next = new Set<number>();
+  for (const job of detail.dispatched_jobs ?? []) {
+    if (typeof job.printer_id === 'number') next.add(job.printer_id);
+  }
+  for (const job of detail.active_jobs ?? []) {
+    if (typeof job.printer_id === 'number') next.add(job.printer_id);
+  }
+  return next;
+}
+
+function handleEvent(event: Event) {
+  const detail = (event as CustomEvent<DispatchEventDetail>).detail ?? {};
+  const next = recompute(detail);
+  // Keep reference stable when content didn't change — useSyncExternalStore
+  // compares snapshots via Object.is and re-renders on any new reference.
+  if (next.size === currentSet.size && [...next].every((id) => currentSet.has(id))) {
+    return;
+  }
+  currentSet = next;
+  subscribers.forEach((cb) => cb());
+}
+
+function ensureAttached() {
+  if (attached || typeof window === 'undefined') return;
+  window.addEventListener('background-dispatch', handleEvent);
+  attached = true;
+}
+
+const subscribe = (callback: () => void): (() => void) => {
+  ensureAttached();
+  subscribers.add(callback);
+  return () => {
+    subscribers.delete(callback);
+  };
+};
+
+const getSnapshot = (): ReadonlySet<number> => currentSet;
+
+export function useDispatchedPrinterIds(): ReadonlySet<number> {
+  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+}
+
+/** Test-only helper — resets the module-level singleton between tests. */
+export function __resetDispatchedPrinterIdsForTests(): void {
+  currentSet = EMPTY;
+  subscribers.clear();
+  if (attached && typeof window !== 'undefined') {
+    window.removeEventListener('background-dispatch', handleEvent);
+    attached = false;
+  }
+}

Some files were not shown because too many files changed in this diff