mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user