feat(slicer): Pipelines — multi-copy + class targeting + fanout + runs dashboard + retry-failed + WS updates (#1425 PR C — completes the v3 design)

PR A/B turned the slice modal's preset bundle into a one-click dispatch
with a pinned target printer. PR C closes the original issue: operators
type in a number of copies, Bambuddy slices once and distributes prints
across a fleet per the pipeline's chosen fanout strategy. A new dashboard
surfaces every run with filters, expandable per-copy status, cancel,
and retry-failed-copies. WS pushes keep everything live.

Backend
- copies field on POST /run, capped by new pipeline_max_copies setting
  (default 50, hard cap 1000). PipelineRun.parent_run_id chains retries.
- SlicerPipelineUpdate accepts target_kind (specific_printer /
  printer_class), target_model_class, fanout_strategy.
- Eligibility matcher branches: class-targeting enumerates matching
  Printer rows, runs per-printer checks via a status_lookup closure,
  returns printer_reports[]. New issue kinds: no_class_matches,
  class_not_set.
- _pick_assignments distributes copies per strategy:
  - max_parallel: target_model set, printer_id None — scheduler picks
  - round_robin: copy i → eligible[i % N], fixed printer_id
  - fill_one_first: all copies pinned to eligible[0]
  All three reuse the slice-once path through slice_dispatch.enqueue.
- New routes:
  - GET /pipeline-runs (paginated, filterable by pipeline + status)
  - POST /pipeline-runs/{id}/retry-failed (creates child run with
    copies = failed+cancelled count, parent_run_id set)
  - Cancel cascades to all N queue entries (only pending/queued)
- _roll_up_run_status computes run-level status from per-job statuses;
  introduces partial_failure for "some completed, some failed".
- ws_manager.broadcast_to_user emits pipeline_run_updated on every
  state transition with the full materialised response.

Frontend
- Pipeline editor: target_kind radio + class picker (filtered to
  installed models) + fanout-strategy radio. Read-only row shows
  "X1C · Round robin" for class pipelines.
- RunWithPipelineModal: copies number input bounded by
  settings.pipeline_max_copies. Accepts class-targeted pipelines.
- Settings → Workflow → Queue & Dispatch: new "Slicer Pipeline limits"
  card with the max-copies input.
- New /pipelines/runs dashboard page (sidebar entry, gated on
  pipelines:read). Two-filter dropdown, 25-per-page pagination, per-row
  expandable to job list, Cancel + Retry-failed buttons.
- useWebSocket case for pipeline_run_updated invalidates both
  pipeline-runs-all and pipeline-runs/{id} query keys.
This commit is contained in:
maziggy
2026-06-27 16:52:05 +02:00
parent 4bbf0f031e
commit 3ef197e4e0
37 changed files with 2476 additions and 364 deletions
@@ -131,6 +131,7 @@ describe('RunWithPipelineModal', () => {
1,
{ kind: 'libraryFile', id: 99 },
false,
1,
);
expect(onClose).toHaveBeenCalled();
});
@@ -165,6 +166,7 @@ describe('RunWithPipelineModal', () => {
1,
{ kind: 'archive', id: 7 },
false,
1,
);
});
});
@@ -202,7 +204,7 @@ describe('RunWithPipelineModal', () => {
// Clicking Run anyway fires the run with force=true and the new source-kind shape.
await user.click(screen.getByRole('button', { name: /Run anyway/i }));
await waitFor(() => {
expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true);
expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true, 1);
});
});
});
@@ -0,0 +1,156 @@
/**
* Tests for PipelineRunsPage — dashboard for Slicer Pipeline runs (#1425 PR C).
*
* Pin the load → list → expand → cancel/retry flow. We mock the api client
* so we can exercise the dashboard without hitting the WS subscription.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PipelineRunsPage } from '../../pages/PipelineRunsPage';
import { api, type PipelineRun } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
listSlicerPipelines: vi.fn(),
listAllPipelineRuns: vi.fn(),
cancelPipelineRun: vi.fn(),
retryFailedPipelineRun: vi.fn(),
// Bootstrap calls — ThemeContext / AuthContext touch these on mount.
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
},
}));
const mockApi = api as unknown as {
listSlicerPipelines: ReturnType<typeof vi.fn>;
listAllPipelineRuns: ReturnType<typeof vi.fn>;
cancelPipelineRun: ReturnType<typeof vi.fn>;
retryFailedPipelineRun: ReturnType<typeof vi.fn>;
};
function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
return {
id: 1,
pipeline_id: 10,
pipeline_name: 'Production Batch',
source_library_file_id: 99,
source_archive_id: null,
source_filename: 'cube.3mf',
parent_run_id: null,
copies: 3,
copies_completed: 1,
copies_failed: 0,
copies_cancelled: 0,
copies_in_progress: 2,
status: 'in_progress',
slice_job_id: 4242,
sliced_library_file_id: 100,
eligibility_overridden: false,
error_message: null,
created_by: null,
created_at: '2026-06-27T15:00:00Z',
started_at: '2026-06-27T15:00:01Z',
completed_at: null,
jobs: [
{
id: 1,
pipeline_run_id: 1,
copy_index: 0,
assigned_printer_id: 5,
assigned_printer_name: 'X1C #1',
queue_entry_id: 200,
status: 'completed',
error_message: null,
dispatched_at: '2026-06-27T15:00:02Z',
completed_at: '2026-06-27T15:30:00Z',
},
{
id: 2,
pipeline_run_id: 1,
copy_index: 1,
assigned_printer_id: 6,
assigned_printer_name: 'X1C #2',
queue_entry_id: 201,
status: 'printing',
error_message: null,
dispatched_at: '2026-06-27T15:00:02Z',
completed_at: null,
},
],
target_kind: 'printer_class',
target_printer_id: null,
target_model_class: 'X1C',
fanout_strategy: 'max_parallel',
...overrides,
};
}
describe('PipelineRunsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [], total: 0 });
});
it('renders empty state when no runs exist', async () => {
render(<PipelineRunsPage />);
await waitFor(() => {
expect(screen.getByText(/No pipeline runs yet/i)).toBeInTheDocument();
});
});
it('lists runs and surfaces the pipeline name + status chip', async () => {
mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
render(<PipelineRunsPage />);
await waitFor(() => {
expect(screen.getByText(/Production Batch/i)).toBeInTheDocument();
// Status chip uses the i18n key.
expect(screen.getAllByText(/printing/i).length).toBeGreaterThan(0);
});
});
it('shows a Cancel button on in-flight runs and fires the mutation', async () => {
mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
mockApi.cancelPipelineRun.mockResolvedValue(makeRun({ status: 'cancelled' }));
render(<PipelineRunsPage />);
const user = userEvent.setup();
await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => expect(mockApi.cancelPipelineRun).toHaveBeenCalledWith(1));
});
it('shows a Retry-failed button on partial_failure runs and fires the mutation', async () => {
mockApi.listAllPipelineRuns.mockResolvedValue({
runs: [
makeRun({
status: 'partial_failure',
copies_completed: 1,
copies_failed: 2,
copies_in_progress: 0,
}),
],
total: 1,
});
mockApi.retryFailedPipelineRun.mockResolvedValue(makeRun({ id: 2, parent_run_id: 1, copies: 2 }));
render(<PipelineRunsPage />);
const user = userEvent.setup();
await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Retry failed/i }));
await waitFor(() => expect(mockApi.retryFailedPipelineRun).toHaveBeenCalledWith(1));
});
it('expands the row to show per-copy jobs', async () => {
mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
render(<PipelineRunsPage />);
const user = userEvent.setup();
await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Expand/i }));
await waitFor(() => {
expect(screen.getByText('X1C #1')).toBeInTheDocument();
expect(screen.getByText('X1C #2')).toBeInTheDocument();
});
});
});
@@ -303,7 +303,7 @@ describe('SettingsPage', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
SIDEBAR_ORDER_KEY,
JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'pipelineRuns', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
);
});
@@ -345,7 +345,7 @@ describe('SettingsPage', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
expect(localStorage.setItem).toHaveBeenCalledWith(
SIDEBAR_ORDER_KEY,
JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'pipelineRuns', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
);
const settingsRow = screen.getAllByText('Settings')
@@ -410,6 +410,7 @@ describe('SettingsPage', () => {
'inventory',
'archives',
'queue',
'pipelineRuns',
'projects',
'files',
'makerworld',