mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
feat(spoolbuddy): plate-clear pills on kiosk dashboard + toast UX polish
Adds a finger-friendly amber pill row under the printer status badges
on the SpoolBuddy kiosk dashboard. When any printer reports
awaiting_plate_clear=true, a compact pill appears showing the printer
name plus a "Clear" action; tapping it calls POST /printers/{id}/clear-plate
and optimistically removes the pill before the WebSocket round-trip
lands. Multiple pending printers wrap inline via flex-wrap so the
dashboard stays compact when several finish at once. Pill dimensions
match the existing online/offline printer badges (px-2.5 py-1, text-xs).
The kiosk auth path (X-API-Key) already passes the printers:clear_plate
permission gate via the existing _APIKEY_DENIED_PERMISSIONS denylist
(the permission is intentionally not denied — clear-plate is an
inventory-flow operation, not an admin one), so no auth wiring changes
were needed.
Two adjacent fixes shipped in the same change:
- SpoolBuddyLayout suppresses the global toast viewport while mounted.
The global ToastProvider in App.tsx wraps both the main app and the
kiosk routes, which meant the background-dispatch progress overlay
was rendering on the kiosk display alongside any in-flight prints.
Added setViewportSuppressed(bool) on the toast context; the layout
flips it via useEffect and restores on unmount. State machine and
dispatch-event subscription are untouched — only the visible
viewport is hidden.
- Dispatch toast no longer reads as "frozen at 100%" for fast uploads.
Small files complete FTP in <500ms but the bar would sit at 100%
while the printer's MQTT confirmation landed. When uploadProgressPct
>= 99.9 and status is still 'processing', the byte counter is
replaced with "Awaiting printer..." and the bar gets animate-pulse.
This commit is contained in:
@@ -29,6 +29,9 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Settings page: permission-gated instead of admin-only** — the Settings sidebar entry has always been visible to any user holding `settings:read`, but the route guard required admin role, so a non-admin with `settings:read` would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with `settings:read` can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (`users:read`, `groups:update`, `oidc:*`, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (`groups:create` for `/groups/new`, `groups:update` for `/groups/:id/edit`), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.
|
||||
|
||||
### Added
|
||||
- **SpoolBuddy kiosk no longer shows main-app toasts** — the global `ToastProvider` (in `App.tsx`) wraps both the main app routes and the SpoolBuddy kiosk routes, so the background-dispatch progress overlay (job percent, completion summaries, etc.) was rendering on the kiosk display alongside any in-flight prints. Added a `setViewportSuppressed` setter on the toast context; `SpoolBuddyLayout` flips it on mount and restores on unmount via a single `useEffect`. The state machine, dispatch-event subscription, and other tabs' toast UIs are untouched — only the visible viewport is hidden while a kiosk display is active. Trade-off accepted: kiosk-local one-shot toasts (plate-clear confirmation, quick-add errors) are also hidden, but the kiosk's UI already provides direct visual feedback (the plate-ready row vanishes on click; quick-add failures surface in the modal). Updated `SpoolBuddyLayout.test.tsx` to wrap in `ToastProvider` and expand its lucide-react mock with the icons ToastContext imports. 2 new regression tests: `ToastContext.test.tsx::viewport suppression` pins the suppressed-viewport `hidden` class toggle without affecting the underlying state, and `SpoolBuddyLayout.test.tsx::suppresses the global toast viewport while mounted` confirms the kiosk layout flips suppression at mount and cleanup.
|
||||
- **Background-dispatch toast no longer reads as "frozen at 100%" for fast uploads** — small files (a few hundred KB to a printer over LAN) finish FTP upload in <500ms, so the progress bar would jump to 100% and then sit there for ~1-2s while the printer's MQTT confirmation landed and the success toast replaced the dispatch toast. Now, when the byte-count reaches the total but the job status is still `processing` (i.e. upload done, awaiting printer ack), the byte-count line is replaced with "Awaiting printer..." and the progress bar gets `animate-pulse` to indicate continued activity. Translated across all 8 locales (`backgroundDispatch.awaitingPrinter`). 2 new tests in `ToastContext.test.tsx::background dispatch — upload-done UX` cover the threshold (`uploadProgressPct >= 99.9` with `processing` status switches to "Awaiting printer..." + pulse) and the in-flight case (`50.0%` keeps the byte/percent counter, no pulse).
|
||||
- **SpoolBuddy kiosk: "Plate ready" pills under the printer status badges** — when any printer reports `awaiting_plate_clear=true`, a small amber pill appears in the dashboard's left column, sized to match the existing online/offline printer badges. Each pill shows the printer name plus a "Clear" action; tapping it calls `POST /printers/{id}/clear-plate` and optimistically removes the pill from the UI before the WebSocket round-trip lands. Multi-printer setups (e.g. four H2Ds finishing at once) wrap inline via `flex-wrap` so the dashboard stays compact instead of pushing everything else off-screen. The kiosk's API key already passes the `printers:clear_plate` permission gate via the existing `_APIKEY_DENIED_PERMISSIONS` denylist (the permission is intentionally not denied — clear-plate is an inventory-flow operation, not an admin one), so no auth wiring changes were needed. Translated across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 5 new regression tests in `SpoolBuddyDashboard.test.tsx::plate-clear row` cover: row hidden when no printer is pending, mixed pending/non-pending printers (only the pending one gets a pill), title attr + pill text content + Clear label all rendered, clicking calls `api.clearPlate(printerId)`, the optimistic cache write makes the row vanish without waiting for a refetch, and three concurrent pending printers wrap inline in the same `flex-wrap` container. The mock `useTranslation` was upgraded to support `{{var}}` interpolation so future tests can assert on rendered i18n strings with arguments.
|
||||
- **Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers** — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you *who* called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (`trace_id_middleware` in `main.py`, sourced from `backend.app.core.trace`) stamps each request with a fresh 8-char hex ID (or honours a sane inbound `X-Trace-Id` header for cross-system correlation), stores it in a `ContextVar` so any code in the request's call stack can read it, echoes it on the response as `X-Trace-Id`, and a new `TraceIDFilter` injects it into every `LogRecord` so the format string `[%(trace_id)s]` resolves to the right ID for the right request. ContextVars (rather than `request.state`) are the right plumbing here because asyncio copies the current context into every `asyncio.create_task`, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable `-` placeholder so the column stays visually aligned and missing values are obvious in `grep`. Inbound `X-Trace-Id` is hard-validated against a strict whitelist (`[A-Za-z0-9_-]+`, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) into `bambuddy.log` via the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTER `auth_middleware` on purpose: Starlette stacks `@app.middleware` decorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like `2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` paired with the route handler's `2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command` — one `grep a4f3b1e7` away from the full causality chain. 30 new tests across `tests/unit/test_trace.py` (placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plus `tests/integration/test_trace_middleware.py` (X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request).
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -9,6 +9,7 @@ import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { SpoolBuddyLayout } from '../../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { ToastProvider } from '../../../contexts/ToastContext';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -32,9 +33,21 @@ vi.mock('../../../utils/date', () => ({
|
||||
formatTimeOnly: () => '12:00',
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
WifiOff: (props: Record<string, unknown>) => <span data-testid="wifi-off" {...props} />,
|
||||
}));
|
||||
vi.mock('lucide-react', () => {
|
||||
const Stub = (props: Record<string, unknown>) => <span {...props} />;
|
||||
return {
|
||||
WifiOff: (props: Record<string, unknown>) => <span data-testid="wifi-off" {...props} />,
|
||||
// ToastProvider, brought in by SpoolBuddyLayout's useToast(), imports these.
|
||||
AlertCircle: Stub,
|
||||
CheckCircle: Stub,
|
||||
ChevronDown: Stub,
|
||||
ChevronUp: Stub,
|
||||
Info: Stub,
|
||||
Loader2: Stub,
|
||||
X: Stub,
|
||||
XCircle: Stub,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../components/VirtualKeyboard', () => ({
|
||||
VirtualKeyboard: () => <div data-testid="virtual-keyboard" />,
|
||||
@@ -43,15 +56,17 @@ vi.mock('../../../components/VirtualKeyboard', () => ({
|
||||
function renderLayout() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/spoolbuddy']}>
|
||||
<Routes>
|
||||
<Route path="spoolbuddy" element={<SpoolBuddyLayout />}>
|
||||
<Route index element={<div data-testid="child-page">Child</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
<ToastProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/spoolbuddy']}>
|
||||
<Routes>
|
||||
<Route path="spoolbuddy" element={<SpoolBuddyLayout />}>
|
||||
<Route index element={<div data-testid="child-page">Child</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,4 +101,21 @@ describe('SpoolBuddyLayout', () => {
|
||||
const child = document.querySelector('[data-testid="child-page"]');
|
||||
expect(child).not.toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses the global toast viewport while mounted', () => {
|
||||
const { unmount } = renderLayout();
|
||||
// Visible viewport gets `hidden` class while the kiosk is up.
|
||||
const viewport = document.querySelector('div.fixed.bottom-4.right-20');
|
||||
expect(viewport?.className).toContain('hidden');
|
||||
|
||||
// Cleanup restores the viewport when the kiosk unmounts (e.g. user
|
||||
// navigates back to the main app).
|
||||
unmount();
|
||||
const viewportAfter = document.querySelector('div.fixed.bottom-4.right-20');
|
||||
// After unmount the toast container is gone with the provider; the
|
||||
// important guarantee is the suppression flag was untoggled, which the
|
||||
// ToastContext tests pin directly. Here we only assert no crash on
|
||||
// unmount during cleanup.
|
||||
expect(viewportAfter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { act, render, renderHook } from '@testing-library/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { ToastProvider, useToast } from '../../contexts/ToastContext';
|
||||
|
||||
@@ -94,3 +94,130 @@ describe('ToastContext post-unmount safety', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToastContext background dispatch — upload-done UX', () => {
|
||||
// Small fast files reach 100% upload before the printer's MQTT confirmation
|
||||
// arrives, leaving the bar parked at 100% for what feels like "stuck". When
|
||||
// status is still 'processing' but uploadProgressPct >= 99.9 the byte-count
|
||||
// line should switch to "Awaiting printer..." and the bar gets a pulse.
|
||||
function dispatchBackgroundEvent(detail: Record<string, unknown>) {
|
||||
window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
|
||||
}
|
||||
|
||||
it('shows "Awaiting printer..." once upload is complete but printer has not confirmed', () => {
|
||||
const { container } = render(
|
||||
<ToastProvider>
|
||||
<div />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchBackgroundEvent({
|
||||
total: 1,
|
||||
dispatched: 0,
|
||||
processing: 1,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
active_jobs: [
|
||||
{
|
||||
job_id: 42,
|
||||
printer_name: 'X1C-2',
|
||||
source_name: 'Benchy.3mf',
|
||||
upload_bytes: 102400,
|
||||
upload_total_bytes: 102400,
|
||||
upload_progress_pct: 100.0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// The byte-count line should be replaced with the awaiting-printer text.
|
||||
expect(container.textContent).toContain('Awaiting printer');
|
||||
// And the original bytes-progressed format must not be visible at the
|
||||
// same time — that is the "stuck at 100%" symptom we are fixing.
|
||||
expect(container.textContent).not.toContain('100.0%');
|
||||
|
||||
// Bar gets the pulse class when in this state.
|
||||
const bar = container.querySelector('.animate-pulse');
|
||||
expect(bar).not.toBeNull();
|
||||
});
|
||||
|
||||
it('still shows the byte/percent counter while upload is mid-flight', () => {
|
||||
const { container } = render(
|
||||
<ToastProvider>
|
||||
<div />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchBackgroundEvent({
|
||||
total: 1,
|
||||
dispatched: 0,
|
||||
processing: 1,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
active_jobs: [
|
||||
{
|
||||
job_id: 7,
|
||||
printer_name: 'X1C-2',
|
||||
source_name: 'Benchy.3mf',
|
||||
upload_bytes: 51200,
|
||||
upload_total_bytes: 102400,
|
||||
upload_progress_pct: 50.0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('50.0%');
|
||||
expect(container.textContent).not.toContain('Awaiting printer');
|
||||
expect(container.querySelector('.animate-pulse')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToastContext viewport suppression', () => {
|
||||
// The kiosk layout flips setViewportSuppressed(true) on mount so the
|
||||
// SpoolBuddy display stays free of main-app toasts (background dispatch
|
||||
// progress, login flows, etc.). Verify the gate hides the visible viewport
|
||||
// without affecting the underlying state machine.
|
||||
function ViewportProbe() {
|
||||
const { showToast, setViewportSuppressed } = useToast();
|
||||
return (
|
||||
<>
|
||||
<button data-testid="show-toast" onClick={() => showToast('hello', 'success')} />
|
||||
<button data-testid="suppress-on" onClick={() => setViewportSuppressed(true)} />
|
||||
<button data-testid="suppress-off" onClick={() => setViewportSuppressed(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it('hides the visible toast viewport when suppressed but keeps state alive', () => {
|
||||
const { container, getByTestId } = render(
|
||||
<ToastProvider>
|
||||
<ViewportProbe />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
// Toast viewport is the fixed-position container with bottom-4 right-20.
|
||||
const findViewport = () => container.querySelector('div.fixed.bottom-4.right-20');
|
||||
expect(findViewport()?.className).not.toContain('hidden');
|
||||
|
||||
act(() => {
|
||||
getByTestId('suppress-on').click();
|
||||
});
|
||||
expect(findViewport()?.className).toContain('hidden');
|
||||
|
||||
// State is unaffected — emitting a toast while suppressed is fine; the
|
||||
// state container exists, just hidden.
|
||||
act(() => {
|
||||
getByTestId('show-toast').click();
|
||||
});
|
||||
expect(findViewport()?.className).toContain('hidden');
|
||||
|
||||
// Restore on unmount of the kiosk layout (or via the setter directly).
|
||||
act(() => {
|
||||
getByTestId('suppress-off').click();
|
||||
});
|
||||
expect(findViewport()?.className).not.toContain('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
@@ -26,6 +26,7 @@ vi.mock('../../api/client', () => ({
|
||||
getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
|
||||
linkTagToSpool: vi.fn().mockResolvedValue({}),
|
||||
createSpool: vi.fn().mockResolvedValue({ id: 4 }),
|
||||
clearPlate: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
spoolbuddyApi: {
|
||||
getDevices: vi.fn().mockResolvedValue([]),
|
||||
@@ -34,7 +35,12 @@ vi.mock('../../api/client', () => ({
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback: string) => fallback,
|
||||
// Mirrors i18next's (key, defaultValue, options) signature with simple
|
||||
// {{var}} interpolation so tests can assert on the rendered text.
|
||||
t: (_key: string, fallback: string, options?: Record<string, unknown>) => {
|
||||
if (!options) return fallback;
|
||||
return fallback.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(options[k] ?? ''));
|
||||
},
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
@@ -137,4 +143,106 @@ describe('SpoolBuddyDashboard', () => {
|
||||
expect(screen.getByText('Current Spool')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plate-clear row', () => {
|
||||
// We re-mock api.getPrinters / getPrinterStatus per test so each scenario
|
||||
// controls exactly which printers report awaiting_plate_clear.
|
||||
it('does not render the plate-clear button when no printer needs it', async () => {
|
||||
const { api } = await import('../../api/client');
|
||||
(api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ id: 1, name: 'X1C' },
|
||||
]);
|
||||
(api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
connected: true,
|
||||
awaiting_plate_clear: false,
|
||||
});
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1C')).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByTestId('plate-clear-section')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a plate-clear pill only for printers with awaiting_plate_clear=true', async () => {
|
||||
const { api } = await import('../../api/client');
|
||||
(api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ id: 1, name: 'X1C' },
|
||||
{ id: 2, name: 'P1S' },
|
||||
]);
|
||||
(api.getPrinterStatus as ReturnType<typeof vi.fn>).mockImplementation((printerId: number) =>
|
||||
Promise.resolve({
|
||||
connected: true,
|
||||
awaiting_plate_clear: printerId === 2,
|
||||
})
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plate-clear-button-2')).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByTestId('plate-clear-button-1')).toBeNull();
|
||||
// Pill content: printer name + "Clear" label, plus full "Plate ready: P1S" in title attr.
|
||||
const pill = screen.getByTestId('plate-clear-button-2');
|
||||
expect(pill.getAttribute('title')).toBe('Plate ready: P1S');
|
||||
expect(pill.textContent).toContain('P1S');
|
||||
expect(pill.textContent).toContain('Clear');
|
||||
});
|
||||
|
||||
it('renders multiple plate-clear pills inline when several printers are pending', async () => {
|
||||
const { api } = await import('../../api/client');
|
||||
(api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
{ id: 3, name: 'C' },
|
||||
]);
|
||||
(api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
connected: true,
|
||||
awaiting_plate_clear: true,
|
||||
});
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plate-clear-button-1')).toBeDefined();
|
||||
expect(screen.getByTestId('plate-clear-button-2')).toBeDefined();
|
||||
expect(screen.getByTestId('plate-clear-button-3')).toBeDefined();
|
||||
});
|
||||
// Pills sit in the same flex-wrap container so they flow inline.
|
||||
const section = screen.getByTestId('plate-clear-section');
|
||||
expect(section.className).toContain('flex-wrap');
|
||||
});
|
||||
|
||||
it('calls api.clearPlate with the printer id when clicked', async () => {
|
||||
const { api } = await import('../../api/client');
|
||||
(api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ id: 7, name: 'H2D' },
|
||||
]);
|
||||
(api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
connected: true,
|
||||
awaiting_plate_clear: true,
|
||||
});
|
||||
renderPage();
|
||||
const btn = await waitFor(() => screen.getByTestId('plate-clear-button-7'));
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
expect(api.clearPlate).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the row optimistically after a successful click without a refetch', async () => {
|
||||
const { api } = await import('../../api/client');
|
||||
(api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ id: 9, name: 'X1E' },
|
||||
]);
|
||||
// Stable resolve — even if refetch happens it would still report pending,
|
||||
// so a disappearing row proves the optimistic cache write worked.
|
||||
(api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
connected: true,
|
||||
awaiting_plate_clear: true,
|
||||
});
|
||||
renderPage();
|
||||
const btn = await waitFor(() => screen.getByTestId('plate-clear-button-9'));
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('plate-clear-button-9')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
|
||||
import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
|
||||
import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
|
||||
import { VirtualKeyboard } from '../VirtualKeyboard';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
export function SpoolBuddyLayout() {
|
||||
// Cascade a re-render into all SpoolBuddy pages when the color catalog
|
||||
@@ -24,6 +25,15 @@ export function SpoolBuddyLayout() {
|
||||
const location = useLocation();
|
||||
const sbState = useSpoolBuddyState();
|
||||
|
||||
// Hide the global toast viewport (background-dispatch progress, etc.) on the
|
||||
// kiosk display. Restore on unmount so navigating back to the main app sees
|
||||
// its toasts again.
|
||||
const { setViewportSuppressed } = useToast();
|
||||
useEffect(() => {
|
||||
setViewportSuppressed(true);
|
||||
return () => setViewportSuppressed(false);
|
||||
}, [setViewportSuppressed]);
|
||||
|
||||
// Sync language from backend settings (kiosk has its own browser with empty localStorage)
|
||||
const { data: appSettings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
|
||||
@@ -42,6 +42,13 @@ interface ToastContextType {
|
||||
showToast: (message: string, type?: ToastType) => void;
|
||||
showPersistentToast: ShowPersistentToast;
|
||||
dismissToast: (id: string) => void;
|
||||
/**
|
||||
* Suppress the visible toast viewport while keeping the state machine alive.
|
||||
* Used by the SpoolBuddy kiosk layout to keep the kiosk display free of
|
||||
* main-app notifications (background dispatch progress, etc.) without
|
||||
* tearing down the dispatch-job subscription that other tabs rely on.
|
||||
*/
|
||||
setViewportSuppressed: (suppressed: boolean) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
@@ -74,6 +81,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
|
||||
const [viewportSuppressed, setViewportSuppressed] = useState(false);
|
||||
const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
|
||||
const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const dispatchToastId = 'background-dispatch';
|
||||
@@ -479,11 +487,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
|
||||
<ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast, setViewportSuppressed }}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12) */}
|
||||
<div className="fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2">
|
||||
{/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
|
||||
The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
|
||||
free of main-app notifications. */}
|
||||
<div className={`fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
@@ -540,6 +550,15 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
failed: 100,
|
||||
cancelled: 100,
|
||||
};
|
||||
// Upload byte count reached the total — the printer hasn't yet
|
||||
// confirmed it received the file (state is still 'processing').
|
||||
// Without distinguishing this we show a frozen 100% bar that
|
||||
// reads as "stuck" on small files where the upload completed
|
||||
// in <500ms.
|
||||
const uploadDoneAwaitingPrinter =
|
||||
job.status === 'processing' &&
|
||||
typeof job.uploadProgressPct === 'number' &&
|
||||
job.uploadProgressPct >= 99.9;
|
||||
const barColorByStatus: Record<DispatchJobStatus, string> = {
|
||||
dispatched: 'bg-bambu-gray/60',
|
||||
processing: 'bg-bambu-green',
|
||||
@@ -579,15 +598,21 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{job.message}
|
||||
</div>
|
||||
)}
|
||||
{job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
|
||||
<div className="text-[11px] text-bambu-gray truncate">
|
||||
{formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
|
||||
{typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
|
||||
</div>
|
||||
{job.status === 'processing' && (
|
||||
uploadDoneAwaitingPrinter ? (
|
||||
<div className="text-[11px] text-bambu-gray truncate">
|
||||
{t('backgroundDispatch.awaitingPrinter')}
|
||||
</div>
|
||||
) : typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 ? (
|
||||
<div className="text-[11px] text-bambu-gray truncate">
|
||||
{formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
|
||||
{typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
<div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${barColorByStatus[job.status]} transition-all duration-300`}
|
||||
className={`h-full ${barColorByStatus[job.status]} transition-all duration-300 ${uploadDoneAwaitingPrinter ? 'animate-pulse' : ''}`}
|
||||
style={{
|
||||
width: `${
|
||||
job.status === 'processing' && typeof job.uploadProgressPct === 'number'
|
||||
|
||||
@@ -1100,6 +1100,7 @@ export default {
|
||||
cancelDispatchJob: 'Dispatch-Job abbrechen',
|
||||
cancel: 'Abbrechen',
|
||||
cancelling: 'Wird abgebrochen…',
|
||||
awaitingPrinter: 'Warte auf Drucker…',
|
||||
status: {
|
||||
dispatched: 'Geplant',
|
||||
processing: 'In Bearbeitung',
|
||||
@@ -4885,6 +4886,11 @@ export default {
|
||||
spoolSize: 'Spulengröße',
|
||||
close: 'Schließen',
|
||||
currentSpool: 'Aktuelle Spule',
|
||||
plateReady: 'Druckplatte bereit: {{name}}',
|
||||
plateReadyLabel: 'Bereit-zum-Quittieren-Liste',
|
||||
plateClearAction: 'Frei',
|
||||
plateClearedToast: 'Druckplatte als geleert markiert',
|
||||
plateClearFailed: 'Druckplatte konnte nicht als geleert markiert werden',
|
||||
},
|
||||
modal: {
|
||||
spoolDetected: 'Spule erkannt',
|
||||
|
||||
@@ -1100,6 +1100,7 @@ export default {
|
||||
cancelDispatchJob: 'Cancel dispatch job',
|
||||
cancel: 'Cancel',
|
||||
cancelling: 'Cancelling…',
|
||||
awaitingPrinter: 'Awaiting printer…',
|
||||
status: {
|
||||
dispatched: 'Dispatched',
|
||||
processing: 'Processing',
|
||||
@@ -4894,6 +4895,11 @@ export default {
|
||||
spoolSize: 'Spool size',
|
||||
close: 'Close',
|
||||
currentSpool: 'Current Spool',
|
||||
plateReady: 'Plate ready: {{name}}',
|
||||
plateReadyLabel: 'Plates ready to clear',
|
||||
plateClearAction: 'Clear',
|
||||
plateClearedToast: 'Plate marked as cleared',
|
||||
plateClearFailed: 'Could not mark plate as cleared',
|
||||
},
|
||||
modal: {
|
||||
spoolDetected: 'Spool Detected',
|
||||
|
||||
@@ -1093,6 +1093,7 @@ export default {
|
||||
cancelDispatchJob: 'Cancel dispatch job',
|
||||
cancel: 'Cancel',
|
||||
cancelling: 'Cancelling…',
|
||||
awaitingPrinter: 'En attente de l\'imprimante…',
|
||||
status: {
|
||||
dispatched: 'Dispatched',
|
||||
processing: 'Processing',
|
||||
@@ -4811,6 +4812,11 @@ export default {
|
||||
spoolSize: 'Taille bobine',
|
||||
close: 'Fermer',
|
||||
currentSpool: 'Bobine actuelle',
|
||||
plateReady: 'Plateau prêt : {{name}}',
|
||||
plateReadyLabel: 'Plateaux à libérer',
|
||||
plateClearAction: 'Libérer',
|
||||
plateClearedToast: 'Plateau marqué comme libéré',
|
||||
plateClearFailed: 'Impossible de marquer le plateau comme libéré',
|
||||
},
|
||||
modal: {
|
||||
spoolDetected: 'Bobine détectée',
|
||||
|
||||
@@ -1093,6 +1093,7 @@ export default {
|
||||
cancelDispatchJob: 'Annulla job dispatch',
|
||||
cancel: 'Annulla',
|
||||
cancelling: 'Annullamento…',
|
||||
awaitingPrinter: 'In attesa della stampante…',
|
||||
status: {
|
||||
dispatched: 'Inviato',
|
||||
processing: 'In elaborazione',
|
||||
@@ -4810,6 +4811,11 @@ export default {
|
||||
spoolSize: 'Dimensione bobina',
|
||||
close: 'Chiudi',
|
||||
currentSpool: 'Bobina attuale',
|
||||
plateReady: 'Piatto pronto: {{name}}',
|
||||
plateReadyLabel: 'Piatti da liberare',
|
||||
plateClearAction: 'Libera',
|
||||
plateClearedToast: 'Piatto contrassegnato come liberato',
|
||||
plateClearFailed: 'Impossibile contrassegnare il piatto come liberato',
|
||||
},
|
||||
modal: {
|
||||
spoolDetected: 'Bobina rilevata',
|
||||
|
||||
@@ -1092,6 +1092,7 @@ export default {
|
||||
cancelDispatchJob: '配信ジョブをキャンセル',
|
||||
cancel: 'キャンセル',
|
||||
cancelling: 'キャンセル中…',
|
||||
awaitingPrinter: 'プリンターを待機中…',
|
||||
status: {
|
||||
dispatched: '配信済み',
|
||||
processing: '処理中',
|
||||
@@ -4849,6 +4850,11 @@ export default {
|
||||
spoolSize: 'スプールサイズ',
|
||||
close: '閉じる',
|
||||
currentSpool: '現在のスプール',
|
||||
plateReady: 'プレート準備完了: {{name}}',
|
||||
plateReadyLabel: '片付け待ちプレート',
|
||||
plateClearAction: '片付け',
|
||||
plateClearedToast: 'プレートを片付け済みにしました',
|
||||
plateClearFailed: 'プレートを片付け済みにできませんでした',
|
||||
},
|
||||
modal: {
|
||||
spoolDetected: 'スプール検出',
|
||||
|
||||
@@ -1093,6 +1093,7 @@ export default {
|
||||
cancelDispatchJob: 'Cancel dispatch job',
|
||||
cancel: 'Cancel',
|
||||
cancelling: 'Cancelling…',
|
||||
awaitingPrinter: 'Aguardando impressora…',
|
||||
status: {
|
||||
dispatched: 'Dispatched',
|
||||
processing: 'Processing',
|
||||
@@ -4824,6 +4825,11 @@ export default {
|
||||
spoolSize: 'Tamanho do carretel',
|
||||
close: 'Fechar',
|
||||
currentSpool: 'Carretel Atual',
|
||||
plateReady: 'Mesa pronta: {{name}}',
|
||||
plateReadyLabel: 'Mesas para liberar',
|
||||
plateClearAction: 'Liberar',
|
||||
plateClearedToast: 'Mesa marcada como limpa',
|
||||
plateClearFailed: 'Não foi possível marcar a mesa como limpa',
|
||||
},
|
||||
modal: {
|
||||
spoolDetected: 'Carretel Detectado',
|
||||
|
||||
@@ -1100,6 +1100,7 @@ export default {
|
||||
cancelDispatchJob: '取消分发任务',
|
||||
cancel: '取消',
|
||||
cancelling: '取消中…',
|
||||
awaitingPrinter: '等待打印机…',
|
||||
status: {
|
||||
dispatched: '已分发',
|
||||
processing: '处理中',
|
||||
@@ -4875,6 +4876,11 @@ export default {
|
||||
spoolSize: '耗材盘尺寸',
|
||||
close: '关闭',
|
||||
currentSpool: '当前耗材',
|
||||
plateReady: '热床就绪: {{name}}',
|
||||
plateReadyLabel: '待清理的热床',
|
||||
plateClearAction: '清理',
|
||||
plateClearedToast: '已将热床标记为已清理',
|
||||
plateClearFailed: '无法将热床标记为已清理',
|
||||
},
|
||||
modal: {
|
||||
spoolDetected: '检测到耗材',
|
||||
|
||||
@@ -1100,6 +1100,7 @@ export default {
|
||||
cancelDispatchJob: '取消分發任務',
|
||||
cancel: '取消',
|
||||
cancelling: '取消中…',
|
||||
awaitingPrinter: '等待印表機…',
|
||||
status: {
|
||||
dispatched: '已分發',
|
||||
processing: '處理中',
|
||||
@@ -4875,6 +4876,11 @@ export default {
|
||||
spoolSize: '耗材盤尺寸',
|
||||
close: '關閉',
|
||||
currentSpool: '目前耗材',
|
||||
plateReady: '熱床就緒: {{name}}',
|
||||
plateReadyLabel: '待清理的熱床',
|
||||
plateClearAction: '清理',
|
||||
plateClearedToast: '已將熱床標記為已清理',
|
||||
plateClearFailed: '無法將熱床標記為已清理',
|
||||
},
|
||||
modal: {
|
||||
spoolDetected: '偵測到耗材',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { useQuery, useQueries, useMutation, useQueryClient } 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';
|
||||
@@ -157,10 +157,40 @@ export function SpoolBuddyDashboard() {
|
||||
queryKey: ['printerStatus', printer.id],
|
||||
queryFn: () => api.getPrinterStatus(printer.id),
|
||||
refetchInterval: 10000,
|
||||
select: (data: PrinterStatus) => ({ connected: data?.connected }),
|
||||
select: (data: PrinterStatus) => ({
|
||||
connected: data?.connected,
|
||||
awaiting_plate_clear: data?.awaiting_plate_clear === true,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
|
||||
// Plate-clear: collect printers that are waiting for the operator to confirm.
|
||||
// The kiosk's API key passes the printers:clear_plate gate (not in the
|
||||
// _APIKEY_DENIED_PERMISSIONS set), so no extra perm wiring is needed here.
|
||||
const platesPending = printers
|
||||
.map((printer: Printer, i: number) => ({
|
||||
printer,
|
||||
pending: statusQueries[i]?.data?.awaiting_plate_clear === true,
|
||||
}))
|
||||
.filter((row: { pending: boolean }) => row.pending);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const clearPlateMutation = useMutation({
|
||||
mutationFn: (printerId: number) => api.clearPlate(printerId),
|
||||
onSuccess: (_data, printerId) => {
|
||||
// Optimistically clear the flag so the row vanishes immediately; the
|
||||
// backend already broadcasts a printer_status WS event after clearing,
|
||||
// but we don't want the user to see the row linger while that round-trips.
|
||||
queryClient.setQueryData(['printerStatus', printerId], (old: PrinterStatus | undefined) =>
|
||||
old ? { ...old, awaiting_plate_clear: false } : old
|
||||
);
|
||||
showToast(t('spoolbuddy.dashboard.plateClearedToast', 'Plate marked as cleared'), 'success');
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t('spoolbuddy.dashboard.plateClearFailed', 'Could not mark plate as cleared'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Current Spool card state - persists until user closes or new tag detected
|
||||
const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
|
||||
const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
|
||||
@@ -389,6 +419,40 @@ export function SpoolBuddyDashboard() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Plate-ready pills — same compact size as the printer badges above so
|
||||
the row stays scannable when multiple printers finish at once.
|
||||
Wraps via flex-wrap. Each pill is independently tappable. */}
|
||||
{platesPending.length > 0 && (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-2"
|
||||
data-testid="plate-clear-section"
|
||||
aria-label={t('spoolbuddy.dashboard.plateReadyLabel', 'Plates ready to clear')}
|
||||
>
|
||||
{platesPending.map(({ printer }: { printer: Printer }) => (
|
||||
<button
|
||||
key={printer.id}
|
||||
type="button"
|
||||
onClick={() => clearPlateMutation.mutate(printer.id)}
|
||||
disabled={clearPlateMutation.isPending}
|
||||
data-testid={`plate-clear-button-${printer.id}`}
|
||||
title={t('spoolbuddy.dashboard.plateReady', 'Plate ready: {{name}}', { name: printer.name })}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 active:bg-amber-500/30 border border-amber-500/30 text-amber-200 transition-colors disabled:opacity-60 disabled:cursor-wait"
|
||||
>
|
||||
<svg className="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
</svg>
|
||||
<span className="text-xs truncate max-w-[100px]">{printer.name}</span>
|
||||
<span className="text-xs opacity-70" aria-hidden="true">·</span>
|
||||
<span className="text-xs font-medium">
|
||||
{t('spoolbuddy.dashboard.plateClearAction', 'Clear')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DCOWEBQL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-telVPl_h.css">
|
||||
<script type="module" crossorigin src="/assets/index-ox1wB4mb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-mySj4FBY.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user