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
+2
View File
@@ -5,6 +5,7 @@ import { Layout } from './components/Layout';
import { PrintersPage } from './pages/PrintersPage';
import { ArchivesPage } from './pages/ArchivesPage';
import { QueuePage } from './pages/QueuePage';
import { PipelineRunsPage } from './pages/PipelineRunsPage';
import { StatsPage } from './pages/StatsPage';
import { SettingsPage } from './pages/SettingsPage';
import { ProfilesPage } from './pages/ProfilesPage';
@@ -197,6 +198,7 @@ function App() {
<Route index element={<PrintersPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="queue" element={<QueuePage />} />
<Route path="pipelines/runs" element={<PermissionRoute permission="pipelines:read"><PipelineRunsPage /></PermissionRoute>} />
<Route path="stats" element={<StatsPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
@@ -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',
+48 -2
View File
@@ -1139,6 +1139,7 @@ export interface AppSettings {
spoolman_url: string;
// Default printer
default_printer_id: number | null;
pipeline_max_copies: number;
// Dark mode theme settings
dark_style: 'classic' | 'glow' | 'vibrant';
dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
@@ -1535,12 +1536,14 @@ export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest> &
// ``target_printer_id: 0`` means "clear the target" — the backend maps that
// to null. Use null in TypeScript for the same intent.
target_printer_id?: number | null;
target_model_class?: string | null;
fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
};
export interface SlicerPipelinesListResponse {
pipelines: SlicerPipeline[];
}
// Slicer Pipeline runs (#1425 PR B)
// Slicer Pipeline runs (#1425 PR B + PR C)
export type PipelineEligibilityKind =
| 'printer_not_set'
| 'printer_not_found'
@@ -1549,18 +1552,29 @@ export type PipelineEligibilityKind =
| 'filament_type_mismatch'
| 'filament_color_mismatch'
| 'ams_slot_missing'
| 'filament_unverified';
| 'filament_unverified'
| 'no_class_matches'
| 'class_not_set';
export interface PipelineEligibilityIssue {
kind: PipelineEligibilityKind;
slot_index: number | null;
expected: string | null;
actual: string | null;
}
export interface PipelinePerPrinterReport {
printer_id: number;
printer_name: string;
ok: boolean;
issues: PipelineEligibilityIssue[];
}
export interface PipelineEligibilityReport {
ok: boolean;
target_kind: 'specific_printer' | 'printer_class';
target_printer_id: number | null;
target_printer_name: string | null;
target_model_class: string | null;
issues: PipelineEligibilityIssue[];
printer_reports: PipelinePerPrinterReport[];
}
export interface PipelineJob {
id: number;
@@ -1588,7 +1602,12 @@ export interface PipelineRun {
source_library_file_id: number | null;
source_archive_id: number | null;
source_filename: string | null;
parent_run_id: number | null;
copies: number;
copies_completed: number;
copies_failed: number;
copies_cancelled: number;
copies_in_progress: number;
status:
| 'queued'
| 'slicing'
@@ -1596,6 +1615,7 @@ export interface PipelineRun {
| 'in_progress'
| 'completed'
| 'failed'
| 'partial_failure'
| 'cancelled';
slice_job_id: number | null;
sliced_library_file_id: number | null;
@@ -1606,9 +1626,14 @@ export interface PipelineRun {
started_at: string | null;
completed_at: string | null;
jobs: PipelineJob[];
target_kind: 'specific_printer' | 'printer_class' | null;
target_printer_id: number | null;
target_model_class: string | null;
fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin' | null;
}
export interface PipelineRunListResponse {
runs: PipelineRun[];
total: number;
}
export interface SliceResponse {
@@ -6343,6 +6368,7 @@ export const api = {
pipelineId: number,
source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
force = false,
copies = 1,
) =>
request<PipelineRun>(`/slicer-pipelines/${pipelineId}/run`, {
method: 'POST',
@@ -6351,16 +6377,36 @@ export const api = {
? { source_library_file_id: source.id }
: { source_archive_id: source.id }),
force,
copies,
}),
}),
listPipelineRuns: (pipelineId: number, limit = 5) =>
request<PipelineRunListResponse>(
`/slicer-pipelines/${pipelineId}/runs?limit=${limit}`,
),
// Dashboard list across all pipelines (#1425 PR C).
listAllPipelineRuns: (params: {
limit?: number;
offset?: number;
pipelineId?: number;
status?: string;
} = {}) => {
const search = new URLSearchParams();
if (params.limit) search.set('limit', String(params.limit));
if (params.offset) search.set('offset', String(params.offset));
if (params.pipelineId) search.set('pipeline_id', String(params.pipelineId));
if (params.status) search.set('status', params.status);
const q = search.toString();
return request<PipelineRunListResponse>(
`/pipeline-runs${q ? '?' + q : ''}`,
);
},
getPipelineRun: (runId: number) =>
request<PipelineRun>(`/pipeline-runs/${runId}`),
cancelPipelineRun: (runId: number) =>
request<PipelineRun>(`/pipeline-runs/${runId}/cancel`, { method: 'POST' }),
retryFailedPipelineRun: (runId: number) =>
request<PipelineRun>(`/pipeline-runs/${runId}/retry-failed`, { method: 'POST' }),
// Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
// Single source of truth shared with backend (PRINTER_MODEL_MAP); the
+2 -1
View File
@@ -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">
+146 -23
View File
@@ -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 (
+13
View File
@@ -11,6 +11,10 @@ interface WebSocketMessage {
data?: Record<string, unknown>;
printer_name?: string;
missing_slots?: Array<{ slot?: string }>;
// Slicer Pipeline run events (#1425 PR C). ``run`` carries the full
// PipelineRunResponse payload — typed loosely here so the WebSocket hook
// doesn't pull the full client.ts types in.
run?: { pipeline_id?: number | null };
}
export function useWebSocket() {
@@ -399,6 +403,15 @@ export function useWebSocket() {
case 'queue_item_failed':
window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
break;
// Slicer Pipeline runs (#1425 PR C). State transitions on the run
// refresh both the dashboard list AND the per-pipeline "Last run"
// chip in Settings → Pipelines.
case 'pipeline_run_updated':
queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
if (message.run?.pipeline_id) {
queryClient.invalidateQueries({ queryKey: ['pipeline-runs', message.run.pipeline_id] });
}
break;
}
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: 'Drucker',
archives: 'Archiv',
queue: 'Druckwarteschlange',
pipelineRuns: 'Pipeline-Läufe',
stats: 'Statistiken',
profiles: 'Profile',
maintenance: 'Wartung',
@@ -101,6 +102,8 @@ export default {
now: 'Jetzt',
collapse: 'Einklappen',
expand: 'Ausklappen',
previous: 'Zurück',
next: 'Weiter',
viewArchive: 'Archiv anzeigen',
viewInFileManager: 'Im Dateimanager anzeigen',
addedBy: 'Hinzugefügt von {{username}}',
@@ -1044,6 +1047,39 @@ export default {
dismiss: 'Schließen',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: 'Pipeline-Läufe',
loading: 'Wird geladen…',
empty: 'Noch keine Pipeline-Läufe.',
filter: {
pipeline: 'Pipeline',
status: 'Status',
all: 'Alle',
},
copies: '{{n}} Kopien',
failedCount: '{{n}} fehlgeschlagen',
copyN: 'Kopie {{n}}',
retryFailed: 'Fehlgeschlagene wiederholen',
retryOf: 'Wiederholung von #{{n}}',
pagination: '{{start}}–{{end}} von {{total}}',
toast: {
cancelled: 'Lauf abgebrochen',
cancelFailed: 'Abbruch fehlgeschlagen',
retryStarted: 'Wiederholung gestartet',
retryFailed: 'Wiederholung fehlgeschlagen',
},
jobStatus: {
pending: 'ausstehend',
awaiting_printer: 'wartet auf Drucker',
queued: 'in Warteschlange',
printing: 'druckt',
completed: 'abgeschlossen',
failed: 'fehlgeschlagen',
cancelled: 'abgebrochen',
},
},
// Queue page
queue: {
filamentShort: {
@@ -2554,6 +2590,12 @@ export default {
},
pipelineLimits: {
title: 'Slicer-Pipeline-Limits',
maxCopiesLabel: 'Max. Kopien pro Lauf',
maxCopiesDesc: 'Obergrenze für die Anzahl an Kopien, die Operatoren beim Ausführen einer Pipeline anfordern können. Serverseitige Obergrenze ist 1000.',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2572,6 +2614,16 @@ export default {
description: 'Beschreibung',
targetPrinter: 'Zieldrucker',
noTarget: '— Kein Ziel —',
targetKind: 'Zielart',
targetKindSpecific: 'Spezifischer Drucker',
targetKindClass: 'Druckerklasse',
targetModelClass: 'Druckermodell',
fanoutStrategy: 'Verteilungsstrategie',
fanout: {
max_parallel: 'Max parallel — auf alle verfügbaren passenden Drucker verteilen',
round_robin: 'Reihum — durch geeignete Drucker rotieren',
fill_one_first: 'Erst einen füllen — alle Kopien an einen Drucker binden',
},
},
action: {
save: 'Speichern',
@@ -2603,6 +2655,7 @@ export default {
in_progress: 'druckt',
completed: 'abgeschlossen',
failed: 'fehlgeschlagen',
partial_failure: 'teilweise fehlgeschlagen',
cancelled: 'abgebrochen',
},
},
@@ -3851,6 +3904,9 @@ export default {
empty: 'Noch keine Pipelines gespeichert. Öffne den Slice-Dialog und klicke „Als Pipeline speichern“, um eine zu erstellen.',
noTarget: 'Kein Zieldrucker festgelegt',
noTargetMessage: 'Diese Pipeline hat keinen Zieldrucker. Öffne sie in den Einstellungen, um einen festzulegen.',
copies: 'Kopien',
copiesHint: 'max. {{n}}',
classTarget: 'Beliebiger {{model}}',
toast: {
started: 'Pipeline-Lauf gestartet',
failed: 'Lauf konnte nicht gestartet werden',
@@ -3864,6 +3920,8 @@ export default {
filamentColor: 'Filament-Slot {{slot}}: Farbe weicht ab (erwartet {{expected}}, AMS hat {{actual}})',
amsSlotMissing: 'AMS-Slot {{slot}} ist auf diesem Drucker nicht verfügbar',
filamentUnverified: 'Filament-Slot {{slot}} stammt aus einem Cloud-/Standard-Preset und konnte nicht statisch verifiziert werden.',
noClassMatches: 'Keine Drucker in dieser Installation entsprechen der Zielmodellklasse der Pipeline ({{expected}}).',
classNotSet: 'Pipeline-Ziel ist auf eine Druckerklasse gesetzt, aber kein Modell wurde gewählt.',
},
},
},
+67
View File
@@ -4,6 +4,7 @@ export default {
printers: 'Printers',
archives: 'Archives',
queue: 'Print Queue',
pipelineRuns: 'Pipeline Runs',
stats: 'Statistics',
profiles: 'Profiles',
maintenance: 'Maintenance',
@@ -101,6 +102,8 @@ export default {
now: 'Now',
collapse: 'Collapse',
expand: 'Expand',
previous: 'Previous',
next: 'Next',
viewArchive: 'View archive',
viewInFileManager: 'View in File Manager',
addedBy: 'Added by {{username}}',
@@ -1050,6 +1053,42 @@ export default {
dismiss: 'Dismiss',
},
// Pipeline Runs dashboard (#1425 PR C). Lists every Slicer Pipeline run
// across every pipeline with status + pipeline filters and pagination.
// Each row expands to per-copy job status; partial-failure runs get a
// Retry-failed button; in-flight runs get a Cancel button.
pipelineRuns: {
title: 'Pipeline Runs',
loading: 'Loading…',
empty: 'No pipeline runs yet.',
filter: {
pipeline: 'Pipeline',
status: 'Status',
all: 'All',
},
copies: '{{n}} copies',
failedCount: '{{n}} failed',
copyN: 'Copy {{n}}',
retryFailed: 'Retry failed',
retryOf: 'retry of #{{n}}',
pagination: '{{start}}–{{end}} of {{total}}',
toast: {
cancelled: 'Run cancelled',
cancelFailed: 'Cancel failed',
retryStarted: 'Retry started',
retryFailed: 'Retry failed',
},
jobStatus: {
pending: 'pending',
awaiting_printer: 'awaiting printer',
queued: 'queued',
printing: 'printing',
completed: 'completed',
failed: 'failed',
cancelled: 'cancelled',
},
},
// Queue page
queue: {
title: 'Print Queue',
@@ -2570,6 +2609,15 @@ export default {
migrationErrorWarning: '{{count}} legacy row(s) failed to re-encrypt at startup. Check server logs and restart Bambuddy to retry.',
},
// Slicer Pipeline limits (#1425 PR C). Admin-tunable cap that constrains
// the copies input in the Run-with-pipeline modal. Lives on the Workflow
// tab's Queue & Dispatch sub-tab.
pipelineLimits: {
title: 'Slicer Pipeline limits',
maxCopiesLabel: 'Max copies per run',
maxCopiesDesc: 'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2588,6 +2636,17 @@ export default {
description: 'Description',
targetPrinter: 'Target printer',
noTarget: '— No target —',
// PR C
targetKind: 'Target type',
targetKindSpecific: 'Specific printer',
targetKindClass: 'Printer class',
targetModelClass: 'Printer model',
fanoutStrategy: 'Fanout strategy',
fanout: {
max_parallel: 'Max parallel — distribute across any idle matching printer',
round_robin: 'Round robin — cycle through eligible printers',
fill_one_first: 'Fill one first — pin all copies to one printer',
},
},
action: {
save: 'Save',
@@ -2620,6 +2679,7 @@ export default {
in_progress: 'printing',
completed: 'completed',
failed: 'failed',
partial_failure: 'partial failure',
cancelled: 'cancelled',
},
},
@@ -3871,6 +3931,10 @@ export default {
empty: 'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.',
noTarget: 'No target printer set',
noTargetMessage: 'This pipeline has no target printer set. Open it in Settings to pick one.',
// PR C — copies input + class-targeted pipelines.
copies: 'Copies',
copiesHint: 'max {{n}}',
classTarget: 'Any {{model}}',
toast: {
started: 'Pipeline run started',
failed: 'Could not start run',
@@ -3884,6 +3948,9 @@ export default {
filamentColor: 'Filament slot {{slot}}: colour differs (expected {{expected}}, AMS has {{actual}})',
amsSlotMissing: 'AMS slot {{slot}} not available on this printer',
filamentUnverified: 'Filament slot {{slot}} comes from a cloud / standard preset and could not be statically verified.',
// PR C — class targeting
noClassMatches: 'No printers in this install match the pipeline\'s target model class ({{expected}}).',
classNotSet: 'Pipeline target is set to a printer class but no model was chosen.',
},
},
},
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: 'Impresoras',
archives: 'Archivos',
queue: 'Cola de impresión',
pipelineRuns: 'Ejecuciones de pipeline',
stats: 'Estadísticas',
profiles: 'Perfiles',
maintenance: 'Mantenimiento',
@@ -101,6 +102,8 @@ export default {
now: 'Ahora',
collapse: 'Contraer',
expand: 'Expandir',
previous: 'Anterior',
next: 'Siguiente',
viewArchive: 'Ver archivo',
viewInFileManager: 'Ver en el gestor de archivos',
addedBy: 'Añadido por {{username}}',
@@ -1044,6 +1047,39 @@ export default {
dismiss: 'Cerrar',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: 'Ejecuciones de pipeline',
loading: 'Cargando…',
empty: 'Aún no hay ejecuciones de pipeline.',
filter: {
pipeline: 'Pipeline',
status: 'Estado',
all: 'Todas',
},
copies: '{{n}} copias',
failedCount: '{{n}} fallidas',
copyN: 'Copia {{n}}',
retryFailed: 'Reintentar fallidas',
retryOf: 'reintento de #{{n}}',
pagination: '{{start}}–{{end}} de {{total}}',
toast: {
cancelled: 'Ejecución cancelada',
cancelFailed: 'Cancelación fallida',
retryStarted: 'Reintento iniciado',
retryFailed: 'Reintento fallido',
},
jobStatus: {
pending: 'pendiente',
awaiting_printer: 'esperando impresora',
queued: 'en cola',
printing: 'imprimiendo',
completed: 'completada',
failed: 'fallida',
cancelled: 'cancelada',
},
},
// Queue page
queue: {
filamentShort: {
@@ -2557,6 +2593,12 @@ export default {
},
pipelineLimits: {
title: 'Límites de pipelines del cortador',
maxCopiesLabel: 'Copias máximas por ejecución',
maxCopiesDesc: 'Límite superior de copias que los operadores pueden solicitar al ejecutar una pipeline. El límite máximo del servidor es 1000.',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2575,6 +2617,16 @@ export default {
description: 'Descripción',
targetPrinter: 'Impresora de destino',
noTarget: '— Sin destino —',
targetKind: 'Tipo de destino',
targetKindSpecific: 'Impresora específica',
targetKindClass: 'Clase de impresora',
targetModelClass: 'Modelo de impresora',
fanoutStrategy: 'Estrategia de distribución',
fanout: {
max_parallel: 'Máximo paralelo — distribuir entre cualquier impresora libre coincidente',
round_robin: 'Round robin — alternar entre impresoras elegibles',
fill_one_first: 'Llenar una primero — fijar todas las copias a una impresora',
},
},
action: {
save: 'Guardar',
@@ -2606,6 +2658,7 @@ export default {
in_progress: 'imprimiendo',
completed: 'completada',
failed: 'fallida',
partial_failure: 'fallo parcial',
cancelled: 'cancelada',
},
},
@@ -3854,6 +3907,9 @@ export default {
empty: 'Aún no hay pipelines guardadas. Abre el diálogo Cortar y haz clic en "Guardar como pipeline" para crear una.',
noTarget: 'Sin impresora de destino',
noTargetMessage: 'Esta pipeline no tiene impresora de destino. Ábrela en Ajustes para elegir una.',
copies: 'Copias',
copiesHint: 'máx {{n}}',
classTarget: 'Cualquier {{model}}',
toast: {
started: 'Ejecución de pipeline iniciada',
failed: 'No se pudo iniciar la ejecución',
@@ -3867,6 +3923,8 @@ export default {
filamentColor: 'Slot de filamento {{slot}}: color distinto (esperado {{expected}}, AMS tiene {{actual}})',
amsSlotMissing: 'Slot AMS {{slot}} no disponible en esta impresora',
filamentUnverified: 'Slot de filamento {{slot}} proviene de un preset de nube / estándar y no se pudo verificar estáticamente.',
noClassMatches: 'Ninguna impresora en esta instalación coincide con la clase de modelo objetivo de la pipeline ({{expected}}).',
classNotSet: 'El destino de la pipeline es una clase de impresora pero no se eligió ningún modelo.',
},
},
},
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: 'Imprimantes',
archives: 'Archives',
queue: 'File d\'attente d\'impression',
pipelineRuns: 'Exécutions de pipeline',
stats: 'Statistiques',
profiles: 'Profils',
maintenance: 'Maintenance',
@@ -101,6 +102,8 @@ export default {
now: 'Maintenant',
collapse: 'Réduire',
expand: 'Développer',
previous: 'Précédent',
next: 'Suivant',
viewArchive: 'Voir l\'archive',
viewInFileManager: 'Voir dans le gestionnaire de fichiers',
addedBy: 'Ajouté par {{username}}',
@@ -1044,6 +1047,39 @@ export default {
dismiss: 'Fermer',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: 'Exécutions de pipeline',
loading: 'Chargement…',
empty: 'Aucune exécution de pipeline pour le moment.',
filter: {
pipeline: 'Pipeline',
status: 'Statut',
all: 'Toutes',
},
copies: '{{n}} copies',
failedCount: '{{n}} échouées',
copyN: 'Copie {{n}}',
retryFailed: 'Réessayer les échouées',
retryOf: 'réessai de #{{n}}',
pagination: '{{start}}–{{end}} sur {{total}}',
toast: {
cancelled: 'Exécution annulée',
cancelFailed: 'Annulation échouée',
retryStarted: 'Réessai démarré',
retryFailed: 'Réessai échoué',
},
jobStatus: {
pending: 'en attente',
awaiting_printer: 'attente imprimante',
queued: 'en file',
printing: 'impression',
completed: 'terminée',
failed: 'échouée',
cancelled: 'annulée',
},
},
// Queue page
queue: {
filamentShort: {
@@ -2543,6 +2579,12 @@ export default {
commandError: 'Échec de l\'envoi de la commande',
},
pipelineLimits: {
title: 'Limites des pipelines du trancheur',
maxCopiesLabel: 'Copies maximales par exécution',
maxCopiesDesc: 'Limite supérieure du nombre de copies que les opérateurs peuvent demander lors de l\'exécution d\'un pipeline. La limite stricte côté serveur est 1000.',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2561,6 +2603,16 @@ export default {
description: 'Description',
targetPrinter: 'Imprimante cible',
noTarget: '— Aucune cible —',
targetKind: 'Type de cible',
targetKindSpecific: 'Imprimante spécifique',
targetKindClass: 'Classe d\'imprimante',
targetModelClass: 'Modèle d\'imprimante',
fanoutStrategy: 'Stratégie de distribution',
fanout: {
max_parallel: 'Max parallèle — distribuer sur toute imprimante libre correspondante',
round_robin: 'Round robin — alterner entre imprimantes éligibles',
fill_one_first: 'Remplir une d\'abord — épingler toutes les copies à une imprimante',
},
},
action: {
save: 'Enregistrer',
@@ -2592,6 +2644,7 @@ export default {
in_progress: 'impression',
completed: 'terminé',
failed: 'échec',
partial_failure: 'échec partiel',
cancelled: 'annulé',
},
},
@@ -3840,6 +3893,9 @@ export default {
empty: 'Aucun pipeline enregistré pour le moment. Ouvrez le dialogue Trancher et cliquez sur « Enregistrer comme pipeline » pour en créer un.',
noTarget: 'Aucune imprimante cible définie',
noTargetMessage: 'Ce pipeline n\'a pas d\'imprimante cible. Ouvrez-le dans les Paramètres pour en choisir une.',
copies: 'Copies',
copiesHint: 'max {{n}}',
classTarget: 'N\'importe quel {{model}}',
toast: {
started: 'Exécution de pipeline démarrée',
failed: 'Impossible de démarrer l\'exécution',
@@ -3853,6 +3909,8 @@ export default {
filamentColor: 'Emplacement filament {{slot}} : couleur différente (attendu {{expected}}, AMS a {{actual}})',
amsSlotMissing: 'Emplacement AMS {{slot}} indisponible sur cette imprimante',
filamentUnverified: 'L\'emplacement filament {{slot}} provient d\'un préréglage cloud / standard et n\'a pas pu être vérifié statiquement.',
noClassMatches: 'Aucune imprimante de cette installation ne correspond à la classe de modèle cible du pipeline ({{expected}}).',
classNotSet: 'La cible du pipeline est une classe d\'imprimante mais aucun modèle n\'a été choisi.',
},
},
},
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: 'Stampanti',
archives: 'Archivi',
queue: 'Coda di stampa',
pipelineRuns: 'Esecuzioni pipeline',
stats: 'Statistiche',
profiles: 'Profili',
maintenance: 'Manutenzione',
@@ -101,6 +102,8 @@ export default {
now: 'Ora',
collapse: 'Comprimi',
expand: 'Espandi',
previous: 'Precedente',
next: 'Successivo',
viewArchive: 'Vedi archivio',
viewInFileManager: 'Vedi nel Gestore file',
addedBy: 'Aggiunto da {{username}}',
@@ -1044,6 +1047,39 @@ export default {
dismiss: 'Chiudi',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: 'Esecuzioni pipeline',
loading: 'Caricamento…',
empty: 'Nessuna esecuzione di pipeline ancora.',
filter: {
pipeline: 'Pipeline',
status: 'Stato',
all: 'Tutte',
},
copies: '{{n}} copie',
failedCount: '{{n}} fallite',
copyN: 'Copia {{n}}',
retryFailed: 'Riprova fallite',
retryOf: 'ritentativo di #{{n}}',
pagination: '{{start}}–{{end}} di {{total}}',
toast: {
cancelled: 'Esecuzione annullata',
cancelFailed: 'Annullamento fallito',
retryStarted: 'Ritentativo avviato',
retryFailed: 'Ritentativo fallito',
},
jobStatus: {
pending: 'in attesa',
awaiting_printer: 'in attesa stampante',
queued: 'in coda',
printing: 'stampa',
completed: 'completata',
failed: 'fallita',
cancelled: 'annullata',
},
},
// Queue page
queue: {
filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
commandError: 'Invio comando non riuscito',
},
pipelineLimits: {
title: 'Limiti pipeline dello slicer',
maxCopiesLabel: 'Copie massime per esecuzione',
maxCopiesDesc: 'Limite superiore alle copie che gli operatori possono richiedere durante l\'esecuzione di una pipeline. Il limite massimo lato server è 1000.',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2560,6 +2602,16 @@ export default {
description: 'Descrizione',
targetPrinter: 'Stampante di destinazione',
noTarget: '— Nessuna destinazione —',
targetKind: 'Tipo destinazione',
targetKindSpecific: 'Stampante specifica',
targetKindClass: 'Classe stampante',
targetModelClass: 'Modello stampante',
fanoutStrategy: 'Strategia di distribuzione',
fanout: {
max_parallel: 'Max parallelo — distribuisci su qualsiasi stampante libera corrispondente',
round_robin: 'Round robin — alterna tra stampanti idonee',
fill_one_first: 'Riempi una prima — assegna tutte le copie a una stampante',
},
},
action: {
save: 'Salva',
@@ -2591,6 +2643,7 @@ export default {
in_progress: 'stampa',
completed: 'completata',
failed: 'fallita',
partial_failure: 'fallimento parziale',
cancelled: 'annullata',
},
},
@@ -3839,6 +3892,9 @@ export default {
empty: 'Nessuna pipeline salvata. Apri il dialogo Slice e fai clic su "Salva come pipeline" per crearne una.',
noTarget: 'Nessuna stampante di destinazione',
noTargetMessage: 'Questa pipeline non ha una stampante di destinazione. Aprila nelle Impostazioni per sceglierne una.',
copies: 'Copie',
copiesHint: 'max {{n}}',
classTarget: 'Qualsiasi {{model}}',
toast: {
started: 'Esecuzione della pipeline avviata',
failed: 'Impossibile avviare l\'esecuzione',
@@ -3852,6 +3908,8 @@ export default {
filamentColor: 'Slot filamento {{slot}}: colore differente (atteso {{expected}}, AMS ha {{actual}})',
amsSlotMissing: 'Slot AMS {{slot}} non disponibile su questa stampante',
filamentUnverified: 'Lo slot filamento {{slot}} proviene da un preset cloud / standard e non può essere verificato staticamente.',
noClassMatches: 'Nessuna stampante in questa installazione corrisponde alla classe di modello target della pipeline ({{expected}}).',
classNotSet: 'La destinazione della pipeline è una classe di stampante ma nessun modello è stato scelto.',
},
},
},
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: 'プリンター',
archives: 'アーカイブ',
queue: '印刷キュー',
pipelineRuns: 'パイプライン実行',
stats: '統計',
profiles: 'プロファイル',
maintenance: 'メンテナンス',
@@ -101,6 +102,8 @@ export default {
now: '今すぐ',
collapse: '折りたたむ',
expand: '展開',
previous: '前へ',
next: '次へ',
viewArchive: 'アーカイブを表示',
viewInFileManager: 'ファイルマネージャーで表示',
addedBy: '{{username}}が追加',
@@ -1043,6 +1046,39 @@ export default {
dismiss: '閉じる',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: 'パイプライン実行',
loading: '読み込み中…',
empty: 'パイプラインの実行はまだありません。',
filter: {
pipeline: 'パイプライン',
status: 'ステータス',
all: 'すべて',
},
copies: '{{n}} 部',
failedCount: '{{n}} 失敗',
copyN: 'コピー {{n}}',
retryFailed: '失敗を再試行',
retryOf: '#{{n}} の再試行',
pagination: '{{total}} 件中 {{start}}–{{end}}',
toast: {
cancelled: '実行をキャンセルしました',
cancelFailed: 'キャンセルに失敗しました',
retryStarted: '再試行を開始しました',
retryFailed: '再試行に失敗しました',
},
jobStatus: {
pending: '保留中',
awaiting_printer: 'プリンター待機中',
queued: '待機中',
printing: '印刷中',
completed: '完了',
failed: '失敗',
cancelled: 'キャンセル',
},
},
// Queue page
queue: {
filamentShort: {
@@ -2554,6 +2590,12 @@ export default {
},
pipelineLimits: {
title: 'スライサーパイプラインの上限',
maxCopiesLabel: '実行あたりの最大コピー数',
maxCopiesDesc: 'パイプライン実行時にオペレーターが要求できるコピー数の上限。サーバー側の上限は 1000 です。',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2572,6 +2614,16 @@ export default {
description: '説明',
targetPrinter: '対象プリンター',
noTarget: '— 対象なし —',
targetKind: '対象種別',
targetKindSpecific: '特定のプリンター',
targetKindClass: 'プリンタークラス',
targetModelClass: 'プリンターモデル',
fanoutStrategy: '分散戦略',
fanout: {
max_parallel: '最大並列 — 一致する空きプリンターに分散',
round_robin: 'ラウンドロビン — 適格なプリンター間で循環',
fill_one_first: '1台ずつ埋める — すべてのコピーを1台に固定',
},
},
action: {
save: '保存',
@@ -2603,6 +2655,7 @@ export default {
in_progress: '印刷中',
completed: '完了',
failed: '失敗',
partial_failure: '部分的失敗',
cancelled: 'キャンセル',
},
},
@@ -3851,6 +3904,9 @@ export default {
empty: '保存されたパイプラインはまだありません。スライスダイアログを開き「パイプラインとして保存」をクリックして作成してください。',
noTarget: '対象プリンターが未設定',
noTargetMessage: 'このパイプラインには対象プリンターが設定されていません。設定で開いて選択してください。',
copies: 'コピー数',
copiesHint: '最大 {{n}}',
classTarget: '任意の {{model}}',
toast: {
started: 'パイプラインの実行を開始しました',
failed: '実行を開始できませんでした',
@@ -3864,6 +3920,8 @@ export default {
filamentColor: 'フィラメントスロット {{slot}}: 色が異なります(期待 {{expected}}、AMS は {{actual}})',
amsSlotMissing: 'AMS スロット {{slot}} はこのプリンターで利用できません',
filamentUnverified: 'フィラメントスロット {{slot}} はクラウド/標準プリセットからのため、静的に検証できませんでした。',
noClassMatches: 'このインストール内に、パイプラインの対象モデルクラス({{expected}})と一致するプリンターがありません。',
classNotSet: 'パイプラインの対象がプリンタークラスに設定されていますが、モデルが選択されていません。',
},
},
},
+58
View File
@@ -3,6 +3,7 @@ export default {
printers: '프린터',
archives: '아카이브',
queue: '대기열',
pipelineRuns: '파이프라인 실행',
stats: '통계',
profiles: '프로필',
maintenance: '유지보수',
@@ -98,6 +99,8 @@ export default {
now: '지금',
collapse: '접기',
expand: '펼치기',
previous: '이전',
next: '다음',
viewArchive: '아카이브 보기',
viewInFileManager: '파일 관리자에서 보기',
addedBy: '{{username}}님이 추가함',
@@ -998,6 +1001,39 @@ export default {
},
dismiss: '닫기',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: '파이프라인 실행',
loading: '불러오는 중…',
empty: '아직 파이프라인 실행이 없습니다.',
filter: {
pipeline: '파이프라인',
status: '상태',
all: '전체',
},
copies: '사본 {{n}}',
failedCount: '실패 {{n}}',
copyN: '사본 {{n}}',
retryFailed: '실패 재시도',
retryOf: '#{{n}}의 재시도',
pagination: '{{total}} 중 {{start}}–{{end}}',
toast: {
cancelled: '실행 취소됨',
cancelFailed: '취소 실패',
retryStarted: '재시도 시작됨',
retryFailed: '재시도 실패',
},
jobStatus: {
pending: '대기 중',
awaiting_printer: '프린터 대기 중',
queued: '대기 중',
printing: '인쇄 중',
completed: '완료됨',
failed: '실패',
cancelled: '취소됨',
},
},
queue: {
title: '인쇄 대기열',
subtitle: '인쇄 작업을 예약하고 관리하세요',
@@ -2410,6 +2446,12 @@ export default {
energyCostBadge: '에너지',
passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함',
pipelineLimits: {
title: '슬라이서 파이프라인 한도',
maxCopiesLabel: '실행당 최대 사본 수',
maxCopiesDesc: '운영자가 파이프라인 실행 시 요청할 수 있는 사본 수의 상한. 서버 측 절대 상한은 1000입니다.',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2428,6 +2470,16 @@ export default {
description: '설명',
targetPrinter: '대상 프린터',
noTarget: '— 대상 없음 —',
targetKind: '대상 유형',
targetKindSpecific: '특정 프린터',
targetKindClass: '프린터 클래스',
targetModelClass: '프린터 모델',
fanoutStrategy: '분산 전략',
fanout: {
max_parallel: '최대 병렬 — 일치하는 유휴 프린터에 분산',
round_robin: '라운드 로빈 — 적격 프린터를 순환',
fill_one_first: '하나 먼저 채우기 — 모든 사본을 한 프린터에 고정',
},
},
action: {
save: '저장',
@@ -2459,6 +2511,7 @@ export default {
in_progress: '인쇄 중',
completed: '완료됨',
failed: '실패',
partial_failure: '부분 실패',
cancelled: '취소됨',
},
},
@@ -3646,6 +3699,9 @@ export default {
empty: '저장된 파이프라인이 없습니다. 슬라이스 대화상자를 열고 "파이프라인으로 저장"을 클릭하여 생성하세요.',
noTarget: '대상 프린터가 설정되지 않음',
noTargetMessage: '이 파이프라인에는 대상 프린터가 없습니다. 설정에서 열어 선택하세요.',
copies: '사본 수',
copiesHint: '최대 {{n}}',
classTarget: '임의의 {{model}}',
toast: {
started: '파이프라인 실행 시작됨',
failed: '실행을 시작할 수 없습니다',
@@ -3659,6 +3715,8 @@ export default {
filamentColor: '필라멘트 슬롯 {{slot}}: 색상이 다릅니다 (예상 {{expected}}, AMS {{actual}})',
amsSlotMissing: 'AMS 슬롯 {{slot}}이(가) 이 프린터에서 사용 불가',
filamentUnverified: '필라멘트 슬롯 {{slot}}은(는) 클라우드/표준 프리셋이며 정적으로 검증할 수 없습니다.',
noClassMatches: '이 설치에서 파이프라인의 대상 모델 클래스({{expected}})와 일치하는 프린터가 없습니다.',
classNotSet: '파이프라인 대상이 프린터 클래스로 설정되었지만 모델이 선택되지 않았습니다.',
},
},
},
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: 'Impressoras',
archives: 'Arquivos',
queue: 'Fila de impressão',
pipelineRuns: 'Execuções de pipeline',
stats: 'Estatísticas',
profiles: 'Perfis',
maintenance: 'Manutenção',
@@ -101,6 +102,8 @@ export default {
now: 'Agora',
collapse: 'Recolher',
expand: 'Expandir',
previous: 'Anterior',
next: 'Próximo',
viewArchive: 'Ver arquivo',
viewInFileManager: 'Ver no Gerenciador de Arquivos',
addedBy: 'Adicionado por {{username}}',
@@ -1044,6 +1047,39 @@ export default {
dismiss: 'Fechar',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: 'Execuções de pipeline',
loading: 'Carregando…',
empty: 'Nenhuma execução de pipeline ainda.',
filter: {
pipeline: 'Pipeline',
status: 'Status',
all: 'Todas',
},
copies: '{{n}} cópias',
failedCount: '{{n}} falharam',
copyN: 'Cópia {{n}}',
retryFailed: 'Repetir falhas',
retryOf: 'nova tentativa de #{{n}}',
pagination: '{{start}}–{{end}} de {{total}}',
toast: {
cancelled: 'Execução cancelada',
cancelFailed: 'Cancelamento falhou',
retryStarted: 'Nova tentativa iniciada',
retryFailed: 'Nova tentativa falhou',
},
jobStatus: {
pending: 'pendente',
awaiting_printer: 'aguardando impressora',
queued: 'na fila',
printing: 'imprimindo',
completed: 'concluída',
failed: 'falhou',
cancelled: 'cancelada',
},
},
// Queue page
queue: {
filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
commandError: 'Falha ao enviar comando',
},
pipelineLimits: {
title: 'Limites de pipelines do slicer',
maxCopiesLabel: 'Cópias máximas por execução',
maxCopiesDesc: 'Limite superior de cópias que operadores podem solicitar ao executar uma pipeline. O limite rígido no lado do servidor é 1000.',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2560,6 +2602,16 @@ export default {
description: 'Descrição',
targetPrinter: 'Impressora de destino',
noTarget: '— Sem destino —',
targetKind: 'Tipo de destino',
targetKindSpecific: 'Impressora específica',
targetKindClass: 'Classe de impressora',
targetModelClass: 'Modelo de impressora',
fanoutStrategy: 'Estratégia de distribuição',
fanout: {
max_parallel: 'Máximo paralelo — distribuir em qualquer impressora ociosa compatível',
round_robin: 'Round robin — alternar entre impressoras elegíveis',
fill_one_first: 'Encher uma primeiro — fixar todas as cópias em uma impressora',
},
},
action: {
save: 'Salvar',
@@ -2591,6 +2643,7 @@ export default {
in_progress: 'imprimindo',
completed: 'concluída',
failed: 'falhou',
partial_failure: 'falha parcial',
cancelled: 'cancelada',
},
},
@@ -3839,6 +3892,9 @@ export default {
empty: 'Ainda não há pipelines salvas. Abra o diálogo Cortar e clique em "Salvar como pipeline" para criar uma.',
noTarget: 'Sem impressora de destino',
noTargetMessage: 'Esta pipeline não tem impressora de destino. Abra-a em Configurações para escolher uma.',
copies: 'Cópias',
copiesHint: 'máx {{n}}',
classTarget: 'Qualquer {{model}}',
toast: {
started: 'Execução da pipeline iniciada',
failed: 'Não foi possível iniciar a execução',
@@ -3852,6 +3908,8 @@ export default {
filamentColor: 'Slot de filamento {{slot}}: cor diferente (esperado {{expected}}, AMS tem {{actual}})',
amsSlotMissing: 'Slot AMS {{slot}} indisponível nesta impressora',
filamentUnverified: 'Slot de filamento {{slot}} vem de uma predefinição da nuvem / padrão e não pôde ser verificado estaticamente.',
noClassMatches: 'Nenhuma impressora nesta instalação corresponde à classe de modelo de destino da pipeline ({{expected}}).',
classNotSet: 'O destino da pipeline está configurado como classe de impressora, mas nenhum modelo foi escolhido.',
},
},
},
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: 'Yazıcılar',
archives: 'Arşivler',
queue: 'Baskı Kuyruğu',
pipelineRuns: 'Pipeline çalıştırmaları',
stats: 'İstatistikler',
profiles: 'Profiller',
maintenance: 'Bakım',
@@ -101,6 +102,8 @@ export default {
now: 'Şimdi',
collapse: 'Daralt',
expand: 'Genişlet',
previous: 'Önceki',
next: 'Sonraki',
viewArchive: 'Arşivi gör',
viewInFileManager: 'Dosya Yöneticisinde gör',
addedBy: '{{username}} tarafından eklendi',
@@ -1045,6 +1048,39 @@ export default {
},
// Kuyruk sayfası
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: 'Pipeline çalıştırmaları',
loading: 'Yükleniyor…',
empty: 'Henüz pipeline çalıştırması yok.',
filter: {
pipeline: 'Pipeline',
status: 'Durum',
all: 'Tümü',
},
copies: '{{n}} kopya',
failedCount: '{{n}} başarısız',
copyN: 'Kopya {{n}}',
retryFailed: 'Başarısızları tekrar dene',
retryOf: '#{{n}} yeniden denemesi',
pagination: '{{total}} içinden {{start}}–{{end}}',
toast: {
cancelled: 'Çalıştırma iptal edildi',
cancelFailed: 'İptal başarısız',
retryStarted: 'Yeniden deneme başlatıldı',
retryFailed: 'Yeniden deneme başarısız',
},
jobStatus: {
pending: 'beklemede',
awaiting_printer: 'yazıcı bekleniyor',
queued: 'kuyrukta',
printing: 'yazdırılıyor',
completed: 'tamamlandı',
failed: 'başarısız',
cancelled: 'iptal edildi',
},
},
queue: {
title: 'Baskı Kuyruğu',
subtitle: 'Baskı işlerinizi zamanlayın ve yönetin',
@@ -2558,6 +2594,12 @@ export default {
},
pipelineLimits: {
title: 'Dilimleyici pipeline limitleri',
maxCopiesLabel: 'Çalıştırma başına maksimum kopya',
maxCopiesDesc: 'Bir pipeline çalıştırıldığında operatörlerin isteyebileceği kopya sayısı üst sınırı. Sunucu tarafı sert sınır 1000\'dir.',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2576,6 +2618,16 @@ export default {
description: 'Açıklama',
targetPrinter: 'Hedef yazıcı',
noTarget: '— Hedef yok —',
targetKind: 'Hedef türü',
targetKindSpecific: 'Belirli yazıcı',
targetKindClass: 'Yazıcı sınıfı',
targetModelClass: 'Yazıcı modeli',
fanoutStrategy: 'Dağıtım stratejisi',
fanout: {
max_parallel: 'Maksimum paralel — eşleşen boş yazıcılara dağıt',
round_robin: 'Sırayla — uygun yazıcılar arasında döndür',
fill_one_first: 'Önce birini doldur — tüm kopyaları tek yazıcıya sabitle',
},
},
action: {
save: 'Kaydet',
@@ -2607,6 +2659,7 @@ export default {
in_progress: 'yazdırılıyor',
completed: 'tamamlandı',
failed: 'başarısız',
partial_failure: 'kısmi başarısızlık',
cancelled: 'iptal edildi',
},
},
@@ -3841,6 +3894,9 @@ export default {
empty: 'Henüz kayıtlı pipeline yok. Dilimleme diyalogunu açın ve oluşturmak için "Pipeline olarak kaydet"e tıklayın.',
noTarget: 'Hedef yazıcı belirlenmemiş',
noTargetMessage: 'Bu pipeline\'ın hedef yazıcısı yok. Birini seçmek için Ayarlar\'da açın.',
copies: 'Kopyalar',
copiesHint: 'maks {{n}}',
classTarget: 'Herhangi {{model}}',
toast: {
started: 'Pipeline çalıştırması başladı',
failed: 'Çalıştırma başlatılamadı',
@@ -3854,6 +3910,8 @@ export default {
filamentColor: 'Filament yuvası {{slot}}: renk farklı (beklenen {{expected}}, AMS\'de {{actual}})',
amsSlotMissing: 'AMS yuvası {{slot}} bu yazıcıda yok',
filamentUnverified: 'Filament yuvası {{slot}} bulut / standart ön ayardan geldiği için statik olarak doğrulanamadı.',
noClassMatches: 'Bu kurulumda pipeline\'ın hedef model sınıfıyla ({{expected}}) eşleşen yazıcı yok.',
classNotSet: 'Pipeline hedefi yazıcı sınıfı olarak ayarlanmış ancak model seçilmemiş.',
},
},
},
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: '打印机',
archives: '归档',
queue: '打印队列',
pipelineRuns: '流水线运行',
stats: '统计',
profiles: '配置文件',
maintenance: '维护',
@@ -101,6 +102,8 @@ export default {
now: '现在',
collapse: '收起',
expand: '展开',
previous: '上一页',
next: '下一页',
viewArchive: '查看归档',
viewInFileManager: '在文件管理器中查看',
addedBy: '由 {{username}} 添加',
@@ -1044,6 +1047,39 @@ export default {
dismiss: '关闭',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: '流水线运行',
loading: '加载中…',
empty: '暂无流水线运行。',
filter: {
pipeline: '流水线',
status: '状态',
all: '全部',
},
copies: '{{n}} 份',
failedCount: '{{n}} 失败',
copyN: '副本 {{n}}',
retryFailed: '重试失败的',
retryOf: '#{{n}} 的重试',
pagination: '{{total}} 中的 {{start}}–{{end}}',
toast: {
cancelled: '运行已取消',
cancelFailed: '取消失败',
retryStarted: '已开始重试',
retryFailed: '重试失败',
},
jobStatus: {
pending: '等待中',
awaiting_printer: '等待打印机',
queued: '排队中',
printing: '打印中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
},
},
// Queue page
queue: {
filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
},
pipelineLimits: {
title: '切片机流水线限制',
maxCopiesLabel: '每次运行最大副本数',
maxCopiesDesc: '操作员运行流水线时可请求的副本数上限。服务器端硬性上限为 1000。',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2560,6 +2602,16 @@ export default {
description: '描述',
targetPrinter: '目标打印机',
noTarget: '— 无目标 —',
targetKind: '目标类型',
targetKindSpecific: '特定打印机',
targetKindClass: '打印机类别',
targetModelClass: '打印机型号',
fanoutStrategy: '分发策略',
fanout: {
max_parallel: '最大并行 — 分发到任何空闲匹配的打印机',
round_robin: '轮询 — 在合格打印机间循环',
fill_one_first: '先填一台 — 将所有副本固定到一台打印机',
},
},
action: {
save: '保存',
@@ -2591,6 +2643,7 @@ export default {
in_progress: '打印中',
completed: '已完成',
failed: '失败',
partial_failure: '部分失败',
cancelled: '已取消',
},
},
@@ -3839,6 +3892,9 @@ export default {
empty: '尚未保存流水线。请打开切片对话框并点击"另存为流水线"创建一个。',
noTarget: '未设置目标打印机',
noTargetMessage: '此流水线没有目标打印机。请在设置中打开并选择一个。',
copies: '副本数',
copiesHint: '最多 {{n}}',
classTarget: '任意 {{model}}',
toast: {
started: '流水线运行已开始',
failed: '无法启动运行',
@@ -3852,6 +3908,8 @@ export default {
filamentColor: '耗材槽 {{slot}}:颜色不同(期望 {{expected}},AMS 实际 {{actual}})',
amsSlotMissing: '此打印机上没有 AMS 槽 {{slot}}',
filamentUnverified: '耗材槽 {{slot}} 来自云端 / 标准预设,无法静态验证。',
noClassMatches: '此安装中没有匹配该流水线目标型号类别({{expected}})的打印机。',
classNotSet: '流水线目标设置为打印机类别但未选择型号。',
},
},
},
+58
View File
@@ -4,6 +4,7 @@ export default {
printers: '印表機',
archives: '歸檔',
queue: '列印佇列',
pipelineRuns: '管線執行',
stats: '統計',
profiles: '設定檔案',
maintenance: '維護',
@@ -101,6 +102,8 @@ export default {
now: '現在',
collapse: '收起',
expand: '展開',
previous: '上一頁',
next: '下一頁',
viewArchive: '檢視歸檔',
viewInFileManager: '在檔案管理器中檢視',
addedBy: '由 {{username}} 新增',
@@ -1044,6 +1047,39 @@ export default {
dismiss: '關閉',
},
// Pipeline Runs dashboard (#1425 PR C).
pipelineRuns: {
title: '管線執行',
loading: '載入中…',
empty: '尚無管線執行。',
filter: {
pipeline: '管線',
status: '狀態',
all: '全部',
},
copies: '{{n}} 份',
failedCount: '{{n}} 失敗',
copyN: '副本 {{n}}',
retryFailed: '重試失敗的',
retryOf: '#{{n}} 的重試',
pagination: '{{total}} 中的 {{start}}–{{end}}',
toast: {
cancelled: '執行已取消',
cancelFailed: '取消失敗',
retryStarted: '已開始重試',
retryFailed: '重試失敗',
},
jobStatus: {
pending: '等待中',
awaiting_printer: '等待印表機',
queued: '排隊中',
printing: '列印中',
completed: '已完成',
failed: '失敗',
cancelled: '已取消',
},
},
// Queue page
queue: {
filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
},
pipelineLimits: {
title: '切片機管線限制',
maxCopiesLabel: '每次執行最大副本數',
maxCopiesDesc: '操作員執行管線時可請求的副本數上限。伺服器端硬性上限為 1000。',
},
// Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
// from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
pipelines: {
@@ -2560,6 +2602,16 @@ export default {
description: '描述',
targetPrinter: '目標印表機',
noTarget: '— 無目標 —',
targetKind: '目標類型',
targetKindSpecific: '特定印表機',
targetKindClass: '印表機類別',
targetModelClass: '印表機型號',
fanoutStrategy: '分發策略',
fanout: {
max_parallel: '最大並行 — 分發到任何空閒匹配的印表機',
round_robin: '輪替 — 在合格印表機間循環',
fill_one_first: '先填一台 — 將所有副本固定到一台印表機',
},
},
action: {
save: '儲存',
@@ -2591,6 +2643,7 @@ export default {
in_progress: '列印中',
completed: '已完成',
failed: '失敗',
partial_failure: '部分失敗',
cancelled: '已取消',
},
},
@@ -3839,6 +3892,9 @@ export default {
empty: '尚未儲存管線。請開啟切片對話框並點擊「另存為管線」建立一個。',
noTarget: '未設定目標印表機',
noTargetMessage: '此管線沒有目標印表機。請在設定中開啟並選擇一個。',
copies: '副本數',
copiesHint: '最多 {{n}}',
classTarget: '任意 {{model}}',
toast: {
started: '管線執行已開始',
failed: '無法啟動執行',
@@ -3852,6 +3908,8 @@ export default {
filamentColor: '耗材槽 {{slot}}:顏色不同(預期 {{expected}},AMS 實際 {{actual}})',
amsSlotMissing: '此印表機上沒有 AMS 槽 {{slot}}',
filamentUnverified: '耗材槽 {{slot}} 來自雲端 / 標準預設,無法靜態驗證。',
noClassMatches: '此安裝中沒有符合該管線目標型號類別({{expected}})的印表機。',
classNotSet: '管線目標設定為印表機類別但未選擇型號。',
},
},
},
+378
View File
@@ -0,0 +1,378 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ChevronDown,
ChevronRight,
Loader2,
RefreshCw,
RotateCcw,
Workflow,
X,
} from 'lucide-react';
import { api, type PipelineRun, type SlicerPipeline } from '../api/client';
import { useToast } from '../contexts/ToastContext';
const STATUSES = [
'',
'queued',
'slicing',
'dispatching',
'in_progress',
'completed',
'partial_failure',
'failed',
'cancelled',
] as const;
const PAGE_LIMIT = 25;
// Dashboard for Slicer Pipeline runs (#1425 PR C).
// Lists every run across every pipeline with status + pipeline filters and
// pagination. Each row expands to show per-copy status; in-flight runs get a
// Cancel button, partial-failure runs get a Retry-failed button.
export function PipelineRunsPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [statusFilter, setStatusFilter] = useState<string>('');
const [pipelineFilter, setPipelineFilter] = useState<number | null>(null);
const [offset, setOffset] = useState(0);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const { data: pipelines } = useQuery({
queryKey: ['slicer-pipelines'],
queryFn: () => api.listSlicerPipelines(),
});
const { data: runsList, isLoading } = useQuery({
queryKey: ['pipeline-runs-all', statusFilter, pipelineFilter, offset],
queryFn: () =>
api.listAllPipelineRuns({
limit: PAGE_LIMIT,
offset,
pipelineId: pipelineFilter ?? undefined,
status: statusFilter || undefined,
}),
refetchInterval: 15_000,
});
const cancelMutation = useMutation({
mutationFn: (runId: number) => api.cancelPipelineRun(runId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
showToast(t('pipelineRuns.toast.cancelled', 'Run cancelled'), 'success');
},
onError: (err: Error) =>
showToast(err.message || t('pipelineRuns.toast.cancelFailed', 'Cancel failed'), 'error'),
});
const retryMutation = useMutation({
mutationFn: (runId: number) => api.retryFailedPipelineRun(runId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
showToast(t('pipelineRuns.toast.retryStarted', 'Retry started'), 'success');
},
onError: (err: Error) =>
showToast(err.message || t('pipelineRuns.toast.retryFailed', 'Retry failed'), 'error'),
});
const runs = runsList?.runs ?? [];
const total = runsList?.total ?? 0;
const pipelinesById: Record<number, SlicerPipeline> = (pipelines?.pipelines ?? []).reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
},
{} as Record<number, SlicerPipeline>,
);
const toggle = (runId: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(runId)) next.delete(runId);
else next.add(runId);
return next;
});
};
return (
<div className="px-4 py-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Workflow className="w-5 h-5 text-bambu-green" />
{t('pipelineRuns.title', 'Pipeline Runs')}
</h1>
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] })}
aria-label={t('common.refresh', 'Refresh')}
className="p-2 text-bambu-gray hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="flex flex-wrap items-center gap-2 mb-4 text-sm">
<label className="text-bambu-gray">
{t('pipelineRuns.filter.pipeline', 'Pipeline')}:{' '}
<select
value={pipelineFilter ?? ''}
onChange={(e) => {
setOffset(0);
setPipelineFilter(e.target.value ? parseInt(e.target.value, 10) : null);
}}
aria-label={t('pipelineRuns.filter.pipeline', 'Pipeline')}
className="ml-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs"
>
<option value="">{t('pipelineRuns.filter.all', 'All')}</option>
{(pipelines?.pipelines ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="text-bambu-gray">
{t('pipelineRuns.filter.status', 'Status')}:{' '}
<select
value={statusFilter}
onChange={(e) => {
setOffset(0);
setStatusFilter(e.target.value);
}}
aria-label={t('pipelineRuns.filter.status', 'Status')}
className="ml-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs"
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s === '' ? t('pipelineRuns.filter.all', 'All') : t(`settings.pipelines.runs.status.${s}`, s)}
</option>
))}
</select>
</label>
</div>
{isLoading && (
<div className="flex items-center gap-2 text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
{t('pipelineRuns.loading', 'Loading…')}
</div>
)}
{!isLoading && runs.length === 0 && (
<p className="text-sm text-bambu-gray">{t('pipelineRuns.empty', 'No pipeline runs yet.')}</p>
)}
{!isLoading && runs.length > 0 && (
<div className="space-y-2">
{runs.map((run) => (
<RunRow
key={run.id}
run={run}
pipeline={run.pipeline_id ? pipelinesById[run.pipeline_id] : undefined}
expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)}
onCancel={() => cancelMutation.mutate(run.id)}
onRetry={() => retryMutation.mutate(run.id)}
cancelling={cancelMutation.isPending}
retrying={retryMutation.isPending}
/>
))}
</div>
)}
{!isLoading && total > PAGE_LIMIT && (
<div className="flex items-center justify-between mt-4 text-sm">
<button
type="button"
onClick={() => setOffset(Math.max(0, offset - PAGE_LIMIT))}
disabled={offset === 0}
className="px-3 py-1.5 rounded border border-bambu-dark-tertiary disabled:opacity-50 text-bambu-gray hover:text-white"
>
{t('common.previous', 'Previous')}
</button>
<span className="text-bambu-gray text-xs">
{t('pipelineRuns.pagination', '{{start}}–{{end}} of {{total}}', {
start: offset + 1,
end: Math.min(offset + PAGE_LIMIT, total),
total,
})}
</span>
<button
type="button"
onClick={() => setOffset(offset + PAGE_LIMIT)}
disabled={offset + PAGE_LIMIT >= total}
className="px-3 py-1.5 rounded border border-bambu-dark-tertiary disabled:opacity-50 text-bambu-gray hover:text-white"
>
{t('common.next', 'Next')}
</button>
</div>
)}
</div>
);
}
function RunRow({
run,
pipeline,
expanded,
onToggle,
onCancel,
onRetry,
cancelling,
retrying,
}: {
run: PipelineRun;
pipeline: SlicerPipeline | undefined;
expanded: boolean;
onToggle: () => void;
onCancel: () => void;
onRetry: () => void;
cancelling: boolean;
retrying: boolean;
}) {
const { t } = useTranslation();
const inFlight = ['queued', 'slicing', 'dispatching', 'in_progress'].includes(run.status);
const partial = run.status === 'partial_failure' || run.status === 'failed';
return (
<div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40">
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={onToggle}
aria-label={expanded ? t('common.collapse', 'Collapse') : t('common.expand', 'Expand')}
aria-expanded={expanded}
className="text-bambu-gray hover:text-white"
>
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-white truncate">
#{run.id} · {pipeline?.name ?? run.pipeline_name ?? '—'}
</span>
<RunStatusChip status={run.status} />
{run.parent_run_id && (
<span className="text-xs text-bambu-gray/60">
({t('pipelineRuns.retryOf', 'retry of #{{n}}', { n: run.parent_run_id })})
</span>
)}
</div>
<div className="text-xs text-bambu-gray mt-0.5">
{run.source_filename ?? '—'} · {new Date(run.created_at).toLocaleString()}
{run.copies > 1 && (
<>
{' '}· {t('pipelineRuns.copies', '{{n}} copies', { n: run.copies })}
</>
)}
{(run.copies_completed > 0 || run.copies_failed > 0) && (
<>
{' '}·{' '}
<span className="text-bambu-green">
{run.copies_completed}
</span>
/{run.copies}
{run.copies_failed > 0 && (
<>
{' '}·{' '}
<span className="text-red-400">
{t('pipelineRuns.failedCount', '{{n}} failed', { n: run.copies_failed })}
</span>
</>
)}
</>
)}
</div>
</div>
<div className="flex items-center gap-1">
{inFlight && (
<button
type="button"
onClick={onCancel}
disabled={cancelling}
aria-label={t('common.cancel', 'Cancel')}
className="text-xs px-2 py-1 text-red-400 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50 flex items-center gap-1"
>
<X className="w-3 h-3" />
{t('common.cancel', 'Cancel')}
</button>
)}
{partial && (
<button
type="button"
onClick={onRetry}
disabled={retrying}
aria-label={t('pipelineRuns.retryFailed', 'Retry failed')}
className="text-xs px-2 py-1 text-bambu-green hover:bg-bambu-dark-tertiary rounded disabled:opacity-50 flex items-center gap-1"
>
<RotateCcw className="w-3 h-3" />
{t('pipelineRuns.retryFailed', 'Retry failed')}
</button>
)}
</div>
</div>
{expanded && (
<div className="border-t border-bambu-dark-tertiary px-3 py-2 space-y-1">
{run.jobs.map((job) => (
<div key={job.id} className="flex items-center gap-2 text-xs text-bambu-gray">
<span className="text-bambu-gray/60 w-12">
{t('pipelineRuns.copyN', 'Copy {{n}}', { n: job.copy_index + 1 })}
</span>
<JobStatusChip status={job.status} />
{job.assigned_printer_name && (
<span className="text-white">{job.assigned_printer_name}</span>
)}
{job.error_message && (
<span className="text-red-400 truncate">{job.error_message}</span>
)}
</div>
))}
{run.error_message && (
<div className="text-xs text-red-400 mt-1">
{run.error_message}
</div>
)}
</div>
)}
</div>
);
}
function RunStatusChip({ status }: { status: PipelineRun['status'] }) {
const { t } = useTranslation();
const colours: Record<PipelineRun['status'], string> = {
queued: 'bg-bambu-gray/20 text-bambu-gray',
slicing: 'bg-blue-500/20 text-blue-300',
dispatching: 'bg-blue-500/20 text-blue-300',
in_progress: 'bg-bambu-green/20 text-bambu-green',
completed: 'bg-bambu-green/20 text-bambu-green',
failed: 'bg-red-500/20 text-red-300',
partial_failure: 'bg-amber-500/20 text-amber-300',
cancelled: 'bg-bambu-gray/20 text-bambu-gray',
};
return (
<span className={`px-1.5 py-0.5 rounded text-[10px] uppercase ${colours[status]}`}>
{t(`settings.pipelines.runs.status.${status}`, status)}
</span>
);
}
function JobStatusChip({ status }: { status: PipelineRun['jobs'][number]['status'] }) {
const { t } = useTranslation();
const colours: Record<PipelineRun['jobs'][number]['status'], string> = {
pending: 'text-bambu-gray',
awaiting_printer: 'text-blue-300',
queued: 'text-blue-300',
printing: 'text-bambu-green',
completed: 'text-bambu-green',
failed: 'text-red-400',
cancelled: 'text-bambu-gray',
};
return (
<span className={colours[status]}>
{t(`pipelineRuns.jobStatus.${status}`, status)}
</span>
);
}
+41
View File
@@ -4425,6 +4425,47 @@ export function SettingsPage() {
</div>
{/* Right Column */}
<div className="lg:w-1/2 space-y-3">
{/* Slicer Pipelines (#1425 PR C). Cap on the copies input in
the Run-with-pipeline modal to prevent fat-fingered queue
floods. Hard ceiling at 1000 enforced server-side. */}
<Card id="card-pipelines">
<CardHeader>
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Workflow className="w-4 h-4 text-bambu-green" />
{t('settings.pipelineLimits.title', 'Slicer Pipeline limits')}
</h3>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex-1">
<p className="text-sm text-white">
{t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
</p>
<p className="text-xs text-bambu-gray mt-0.5">
{t(
'settings.pipelineLimits.maxCopiesDesc',
'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
)}
</p>
</div>
<input
type="number"
min={1}
max={1000}
value={localSettings.pipeline_max_copies ?? 50}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (Number.isNaN(n)) return;
updateSetting('pipeline_max_copies', Math.max(1, Math.min(1000, n)));
}}
aria-label={t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
className="w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
/>
</div>
</CardContent>
</Card>
{/* Slicer */}
<Card id="card-slicer">
<CardHeader>