mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +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:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Workflow, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
@@ -42,6 +42,7 @@ export const defaultNavItems: NavItem[] = [
|
||||
{ id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
|
||||
{ id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
|
||||
{ id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
|
||||
{ id: 'pipelineRuns', to: '/pipelines/runs', icon: Workflow, labelKey: 'nav.pipelineRuns' },
|
||||
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
|
||||
{ id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
|
||||
|
||||
@@ -34,6 +34,7 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
|
||||
|
||||
const [picked, setPicked] = useState<SlicerPipeline | null>(null);
|
||||
const [report, setReport] = useState<PipelineEligibilityReport | null>(null);
|
||||
const [copies, setCopies] = useState<number>(1);
|
||||
const { trackJob } = useSliceJobTracker();
|
||||
|
||||
const { data: list, isLoading: pipelinesLoading } = useQuery({
|
||||
@@ -44,6 +45,13 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
|
||||
queryKey: ['printers'],
|
||||
queryFn: () => api.getPrinters(),
|
||||
});
|
||||
// Cap from settings (PR C). Falls back to 50 when the fetch is in-flight or
|
||||
// missing — same default the backend writes.
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => api.getSettings(),
|
||||
});
|
||||
const maxCopies = settings?.pipeline_max_copies ?? 50;
|
||||
|
||||
const sourceRef = { kind: source.kind, id: source.id } as const;
|
||||
const checkMutation = useMutation({
|
||||
@@ -53,7 +61,7 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: ({ pipelineId, force }: { pipelineId: number; force: boolean }) =>
|
||||
api.runPipeline(pipelineId, sourceRef, force),
|
||||
api.runPipeline(pipelineId, sourceRef, force, copies),
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['pipeline-runs'] });
|
||||
@@ -78,7 +86,10 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
|
||||
}, {} as Record<number, PrinterType>);
|
||||
|
||||
const handlePick = async (pipeline: SlicerPipeline) => {
|
||||
if (!pipeline.target_printer_id) {
|
||||
const hasTarget =
|
||||
pipeline.target_printer_id ||
|
||||
(pipeline.target_kind === 'printer_class' && pipeline.target_model_class);
|
||||
if (!hasTarget) {
|
||||
showToast(
|
||||
t('library.runWithPipeline.noTargetMessage', 'This pipeline has no target printer set. Open it in Settings to pick one.'),
|
||||
'error',
|
||||
@@ -154,6 +165,9 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
|
||||
printerById={printerById}
|
||||
loading={pipelinesLoading || checkMutation.isPending}
|
||||
onPick={handlePick}
|
||||
copies={copies}
|
||||
maxCopies={maxCopies}
|
||||
onCopiesChange={setCopies}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -168,12 +182,18 @@ function PickStep({
|
||||
printerById,
|
||||
loading,
|
||||
onPick,
|
||||
copies,
|
||||
maxCopies,
|
||||
onCopiesChange,
|
||||
}: {
|
||||
source: { filename: string };
|
||||
pipelines: SlicerPipeline[];
|
||||
printerById: Record<number, { name: string }>;
|
||||
loading: boolean;
|
||||
onPick: (p: SlicerPipeline) => void;
|
||||
copies: number;
|
||||
maxCopies: number;
|
||||
onCopiesChange: (n: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -182,6 +202,28 @@ function PickStep({
|
||||
{t('library.runWithPipeline.sourceHint', 'Source')}:{' '}
|
||||
<span className="text-white">{source.filename}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-bambu-gray" htmlFor="run-pipeline-copies">
|
||||
{t('library.runWithPipeline.copies', 'Copies')}:
|
||||
</label>
|
||||
<input
|
||||
id="run-pipeline-copies"
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxCopies}
|
||||
value={copies}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (Number.isNaN(n)) return;
|
||||
onCopiesChange(Math.max(1, Math.min(maxCopies, n)));
|
||||
}}
|
||||
aria-label={t('library.runWithPipeline.copies', 'Copies')}
|
||||
className="w-20 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
|
||||
/>
|
||||
<span className="text-xs text-bambu-gray/60">
|
||||
{t('library.runWithPipeline.copiesHint', 'max {{n}}', { n: maxCopies })}
|
||||
</span>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -199,13 +241,18 @@ function PickStep({
|
||||
{!loading && pipelines.length > 0 && (
|
||||
<ul className="space-y-1.5" aria-label={t('library.runWithPipeline.pipelineListAria', 'Available pipelines')}>
|
||||
{pipelines.map((p) => {
|
||||
const isClass = p.target_kind === 'printer_class';
|
||||
const targetName = p.target_printer_id ? printerById[p.target_printer_id]?.name : null;
|
||||
const classLabel = isClass && p.target_model_class
|
||||
? t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: p.target_model_class })
|
||||
: null;
|
||||
const hasTarget = !!(p.target_printer_id || (isClass && p.target_model_class));
|
||||
return (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(p)}
|
||||
disabled={!p.target_printer_id}
|
||||
disabled={!hasTarget}
|
||||
className="w-full text-left px-3 py-2 rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -214,7 +261,9 @@ function PickStep({
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
|
||||
<PrinterIcon className="w-3 h-3" />
|
||||
{targetName ? (
|
||||
{classLabel ? (
|
||||
<span>{classLabel}</span>
|
||||
) : targetName ? (
|
||||
<span>{targetName}</span>
|
||||
) : (
|
||||
<span className="text-amber-400">
|
||||
|
||||
@@ -171,6 +171,8 @@ function PipelineRow({
|
||||
description?: string | null;
|
||||
target_printer_id?: number | null;
|
||||
target_kind?: 'specific_printer' | 'printer_class';
|
||||
target_model_class?: string | null;
|
||||
fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
|
||||
}) => void;
|
||||
onDelete: () => void;
|
||||
saving: boolean;
|
||||
@@ -183,6 +185,22 @@ function PipelineRow({
|
||||
const [draftTargetPrinterId, setDraftTargetPrinterId] = useState<number | null>(
|
||||
pipeline.target_printer_id,
|
||||
);
|
||||
// PR C: target kind, model class, and fanout strategy.
|
||||
const [draftTargetKind, setDraftTargetKind] = useState<'specific_printer' | 'printer_class'>(
|
||||
pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer',
|
||||
);
|
||||
const [draftTargetModelClass, setDraftTargetModelClass] = useState<string>(
|
||||
pipeline.target_model_class ?? '',
|
||||
);
|
||||
const [draftFanout, setDraftFanout] = useState<'max_parallel' | 'fill_one_first' | 'round_robin'>(
|
||||
pipeline.fanout_strategy ?? 'max_parallel',
|
||||
);
|
||||
// Installed model classes — derived from the loaded printers list so the
|
||||
// dropdown only offers models the user actually has. Same data the row
|
||||
// header uses, no second fetch.
|
||||
const installedModels = Array.from(
|
||||
new Set(printers.map((p) => p.model).filter((m): m is string => !!m)),
|
||||
).sort();
|
||||
|
||||
// Recent runs for the inline last-run summary. ``enabled: editing === false``
|
||||
// avoids re-querying every keystroke while the editor is open.
|
||||
@@ -203,7 +221,10 @@ function PipelineRow({
|
||||
const targetPrinter = pipeline.target_printer_id
|
||||
? printers.find((p) => p.id === pipeline.target_printer_id)
|
||||
: undefined;
|
||||
const needsTarget = pipeline.target_printer_id === null;
|
||||
const isClassTargeting = pipeline.target_kind === 'printer_class';
|
||||
const needsTarget = isClassTargeting
|
||||
? !pipeline.target_model_class
|
||||
: pipeline.target_printer_id === null;
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = draftName.trim();
|
||||
@@ -211,9 +232,13 @@ function PipelineRow({
|
||||
onSave({
|
||||
name: trimmedName,
|
||||
description: draftDescription.trim() || null,
|
||||
target_kind: 'specific_printer',
|
||||
target_kind: draftTargetKind,
|
||||
// Backend treats 0 as "clear"; null in TS maps to that intent.
|
||||
target_printer_id: draftTargetPrinterId ?? 0,
|
||||
target_printer_id:
|
||||
draftTargetKind === 'specific_printer' ? (draftTargetPrinterId ?? 0) : 0,
|
||||
target_model_class:
|
||||
draftTargetKind === 'printer_class' ? (draftTargetModelClass || null) : null,
|
||||
fanout_strategy: draftFanout,
|
||||
});
|
||||
setEditing(false);
|
||||
};
|
||||
@@ -222,6 +247,9 @@ function PipelineRow({
|
||||
setDraftName(pipeline.name);
|
||||
setDraftDescription(pipeline.description ?? '');
|
||||
setDraftTargetPrinterId(pipeline.target_printer_id);
|
||||
setDraftTargetKind(pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer');
|
||||
setDraftTargetModelClass(pipeline.target_model_class ?? '');
|
||||
setDraftFanout(pipeline.fanout_strategy ?? 'max_parallel');
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
@@ -246,28 +274,110 @@ function PipelineRow({
|
||||
rows={2}
|
||||
className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
|
||||
/>
|
||||
{/* PR C — target-kind radio (Specific printer / Printer class)
|
||||
drives whether the printer dropdown or the class picker is
|
||||
active. Both fields are kept on state so toggling back and
|
||||
forth doesn't lose the user's previous pick. */}
|
||||
<div>
|
||||
<label className="text-xs text-bambu-gray block mb-1">
|
||||
{t('settings.pipelines.field.targetPrinter', 'Target printer')}
|
||||
{t('settings.pipelines.field.targetKind', 'Target type')}
|
||||
</label>
|
||||
<select
|
||||
value={draftTargetPrinterId ?? ''}
|
||||
onChange={(e) =>
|
||||
setDraftTargetPrinterId(e.target.value ? parseInt(e.target.value, 10) : null)
|
||||
}
|
||||
aria-label={t('settings.pipelines.field.targetPrinter', 'Target printer')}
|
||||
className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
|
||||
>
|
||||
<option value="">
|
||||
{t('settings.pipelines.field.noTarget', '— No target —')}
|
||||
</option>
|
||||
{printers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<label className="flex items-center gap-1 text-white">
|
||||
<input
|
||||
type="radio"
|
||||
name={`target-kind-${pipeline.id}`}
|
||||
value="specific_printer"
|
||||
checked={draftTargetKind === 'specific_printer'}
|
||||
onChange={() => setDraftTargetKind('specific_printer')}
|
||||
aria-label={t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}
|
||||
/>
|
||||
{t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-white">
|
||||
<input
|
||||
type="radio"
|
||||
name={`target-kind-${pipeline.id}`}
|
||||
value="printer_class"
|
||||
checked={draftTargetKind === 'printer_class'}
|
||||
onChange={() => setDraftTargetKind('printer_class')}
|
||||
aria-label={t('settings.pipelines.field.targetKindClass', 'Printer class')}
|
||||
/>
|
||||
{t('settings.pipelines.field.targetKindClass', 'Printer class')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draftTargetKind === 'specific_printer' ? (
|
||||
<div>
|
||||
<label className="text-xs text-bambu-gray block mb-1">
|
||||
{t('settings.pipelines.field.targetPrinter', 'Target printer')}
|
||||
</label>
|
||||
<select
|
||||
value={draftTargetPrinterId ?? ''}
|
||||
onChange={(e) =>
|
||||
setDraftTargetPrinterId(e.target.value ? parseInt(e.target.value, 10) : null)
|
||||
}
|
||||
aria-label={t('settings.pipelines.field.targetPrinter', 'Target printer')}
|
||||
className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
|
||||
>
|
||||
<option value="">
|
||||
{t('settings.pipelines.field.noTarget', '— No target —')}
|
||||
</option>
|
||||
{printers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-bambu-gray block mb-1">
|
||||
{t('settings.pipelines.field.targetModelClass', 'Printer model')}
|
||||
</label>
|
||||
<select
|
||||
value={draftTargetModelClass}
|
||||
onChange={(e) => setDraftTargetModelClass(e.target.value)}
|
||||
aria-label={t('settings.pipelines.field.targetModelClass', 'Printer model')}
|
||||
className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
|
||||
>
|
||||
<option value="">
|
||||
{t('settings.pipelines.field.noTarget', '— No target —')}
|
||||
</option>
|
||||
{installedModels.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-bambu-gray block mb-1">
|
||||
{t('settings.pipelines.field.fanoutStrategy', 'Fanout strategy')}
|
||||
</label>
|
||||
<select
|
||||
value={draftFanout}
|
||||
onChange={(e) =>
|
||||
setDraftFanout(e.target.value as 'max_parallel' | 'fill_one_first' | 'round_robin')
|
||||
}
|
||||
aria-label={t('settings.pipelines.field.fanoutStrategy', 'Fanout strategy')}
|
||||
className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
|
||||
>
|
||||
<option value="max_parallel">
|
||||
{t('settings.pipelines.field.fanout.max_parallel', 'Max parallel — distribute across any idle matching printer')}
|
||||
</option>
|
||||
<option value="round_robin">
|
||||
{t('settings.pipelines.field.fanout.round_robin', 'Round robin — cycle through eligible printers')}
|
||||
</option>
|
||||
<option value="fill_one_first">
|
||||
{t('settings.pipelines.field.fanout.fill_one_first', 'Fill one first — pin all copies to one printer')}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -354,9 +464,21 @@ function PipelineRow({
|
||||
<div className="text-bambu-gray flex items-center gap-1">
|
||||
<PrinterIcon className="w-3 h-3" />
|
||||
<span className="font-medium text-bambu-gray/80">
|
||||
{t('settings.pipelines.field.targetPrinter', 'Target printer')}:
|
||||
{isClassTargeting
|
||||
? t('settings.pipelines.field.targetModelClass', 'Printer model')
|
||||
: t('settings.pipelines.field.targetPrinter', 'Target printer')}
|
||||
:
|
||||
</span>{' '}
|
||||
{targetPrinter ? (
|
||||
{isClassTargeting && pipeline.target_model_class ? (
|
||||
<span className="text-white">
|
||||
{pipeline.target_model_class}
|
||||
{pipeline.fanout_strategy && (
|
||||
<span className="text-bambu-gray/60">
|
||||
{' '}· {t(`settings.pipelines.field.fanout.${pipeline.fanout_strategy}`, pipeline.fanout_strategy)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : targetPrinter ? (
|
||||
<span className="text-white">{targetPrinter.name}</span>
|
||||
) : (
|
||||
<span className="text-amber-400">
|
||||
@@ -413,6 +535,7 @@ function RunStatusBadge({ status }: { status: PipelineRun['status'] }) {
|
||||
in_progress: 'text-bambu-green',
|
||||
completed: 'text-bambu-green',
|
||||
failed: 'text-red-400',
|
||||
partial_failure: 'text-amber-400',
|
||||
cancelled: 'text-bambu-gray',
|
||||
};
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user