mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: 'パイプラインの対象がプリンタークラスに設定されていますが、モデルが選択されていません。',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: '파이프라인 대상이 프린터 클래스로 설정되었지만 모델이 선택되지 않았습니다.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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ş.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: '流水线目标设置为打印机类别但未选择型号。',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: '管線目標設定為印表機類別但未選擇型號。',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user