SlicerPipelinesPanel.tsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  1. import { useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Search, Trash2, Workflow, X } from 'lucide-react';
  5. import {
  6. api,
  7. type PipelineRun,
  8. type PresetRef,
  9. type PresetSource,
  10. type Printer as PrinterType,
  11. type SlicerPipeline,
  12. type UnifiedPresetsResponse,
  13. } from '../api/client';
  14. import { Card, CardContent, CardHeader } from './Card';
  15. import { useToast } from '../contexts/ToastContext';
  16. // Resolve a PresetRef back to its pretty name via the unified-presets listing.
  17. // Returns null when the ref no longer points at a known preset — render a
  18. // "deleted" badge in that case so users can see what to fix.
  19. function resolveName(presets: UnifiedPresetsResponse | undefined, slot: 'printer' | 'process' | 'filament', ref: PresetRef): string | null {
  20. if (!presets) return null;
  21. const list = presets[ref.source]?.[slot] ?? [];
  22. const hit = list.find((p) => p.id === ref.id);
  23. return hit ? hit.name : null;
  24. }
  25. const SOURCE_LABEL: Record<PresetSource, string> = {
  26. orca_cloud: 'Orca Cloud',
  27. cloud: 'Bambu Cloud',
  28. local: 'Imported',
  29. standard: 'Standard',
  30. };
  31. export function SlicerPipelinesPanel() {
  32. const { t } = useTranslation();
  33. const queryClient = useQueryClient();
  34. const { showToast } = useToast();
  35. const { data: list, isLoading, error } = useQuery({
  36. queryKey: ['slicer-pipelines'],
  37. queryFn: () => api.listSlicerPipelines(),
  38. });
  39. // The unified presets endpoint is the source of pretty names for each
  40. // PresetRef. Same listing the SliceModal pulls — reused here to avoid a
  41. // second round-trip to the slicer registry.
  42. const { data: presets } = useQuery({
  43. queryKey: ['slicer-presets'],
  44. queryFn: () => api.getSlicerPresets(),
  45. });
  46. // Printers list for the target picker (PR B).
  47. const { data: printers } = useQuery({
  48. queryKey: ['printers'],
  49. queryFn: () => api.getPrinters(),
  50. });
  51. const updateMutation = useMutation({
  52. mutationFn: ({
  53. id,
  54. name,
  55. description,
  56. target_printer_id,
  57. target_kind,
  58. target_model_class,
  59. fanout_strategy,
  60. }: {
  61. id: number;
  62. name?: string;
  63. description?: string | null;
  64. target_printer_id?: number | null;
  65. target_kind?: 'specific_printer' | 'printer_class';
  66. target_model_class?: string | null;
  67. fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
  68. }) =>
  69. api.updateSlicerPipeline(id, {
  70. name,
  71. description,
  72. target_printer_id,
  73. target_kind,
  74. target_model_class,
  75. fanout_strategy,
  76. }),
  77. onSuccess: () => {
  78. queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
  79. showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
  80. },
  81. onError: (err: Error) => {
  82. showToast(err.message || t('settings.pipelines.toast.saveFailed', 'Save failed'), 'error');
  83. },
  84. });
  85. const deleteMutation = useMutation({
  86. mutationFn: (id: number) => api.deleteSlicerPipeline(id),
  87. onSuccess: () => {
  88. queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
  89. showToast(t('settings.pipelines.toast.deleted', 'Pipeline deleted'), 'success');
  90. },
  91. onError: (err: Error) => {
  92. showToast(err.message || t('settings.pipelines.toast.deleteFailed', 'Delete failed'), 'error');
  93. },
  94. });
  95. // Panel-level search + filter (#1425 PR C polish). Filters by pipeline name
  96. // (case-insensitive substring) and by target — the dropdown lists every
  97. // distinct target in use across the saved pipelines so operators can jump
  98. // straight to "show me everything for X1C #2" or "everything for the H2D
  99. // class". State is local — list is small enough that re-rendering on every
  100. // keystroke is fine.
  101. const [searchTerm, setSearchTerm] = useState('');
  102. // Encoded target filter value: '' = all, 'none' = no target set,
  103. // 'p:<printer_id>' = specific printer, 'c:<model_class>' = printer class.
  104. const [targetFilter, setTargetFilter] = useState<string>('');
  105. const allPipelines = useMemo(() => list?.pipelines ?? [], [list?.pipelines]);
  106. // Build the dropdown's options from the targets actually in use. Only
  107. // printers / classes that at least one pipeline points at appear — keeps
  108. // the dropdown short and meaningful for installs with many printers but
  109. // few pipelines.
  110. const targetOptions = useMemo(() => {
  111. const printerIds = new Set<number>();
  112. const classes = new Set<string>();
  113. let anyWithoutTarget = false;
  114. for (const p of allPipelines) {
  115. if (p.target_kind === 'printer_class' && p.target_model_class) {
  116. classes.add(p.target_model_class);
  117. } else if (p.target_printer_id) {
  118. printerIds.add(p.target_printer_id);
  119. } else {
  120. anyWithoutTarget = true;
  121. }
  122. }
  123. return {
  124. printers: (printers ?? []).filter((pr) => printerIds.has(pr.id)),
  125. classes: Array.from(classes).sort(),
  126. anyWithoutTarget,
  127. };
  128. }, [allPipelines, printers]);
  129. const pipelines = useMemo(() => {
  130. const term = searchTerm.trim().toLowerCase();
  131. return allPipelines.filter((p) => {
  132. if (term && !p.name.toLowerCase().includes(term)) return false;
  133. if (targetFilter === 'none') {
  134. const hasTarget = p.target_kind === 'printer_class'
  135. ? !!p.target_model_class
  136. : p.target_printer_id !== null;
  137. if (hasTarget) return false;
  138. } else if (targetFilter.startsWith('p:')) {
  139. const wantId = parseInt(targetFilter.slice(2), 10);
  140. if (p.target_kind === 'printer_class' || p.target_printer_id !== wantId) return false;
  141. } else if (targetFilter.startsWith('c:')) {
  142. const wantClass = targetFilter.slice(2);
  143. if (p.target_kind !== 'printer_class' || p.target_model_class !== wantClass) return false;
  144. }
  145. return true;
  146. });
  147. }, [allPipelines, searchTerm, targetFilter]);
  148. return (
  149. <Card>
  150. <CardHeader>
  151. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  152. <Workflow className="w-4 h-4 text-bambu-green" />
  153. {t('settings.pipelines.title', 'Slicer Pipelines')}
  154. </h3>
  155. <p className="text-xs text-bambu-gray mt-1">
  156. {t(
  157. 'settings.pipelines.subtitle',
  158. 'Reusable preset bundles (printer + process + filaments + bed type). Save one from the Slice dialog and apply it with a single click on the next file.',
  159. )}
  160. </p>
  161. </CardHeader>
  162. <CardContent>
  163. {isLoading && (
  164. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  165. <Loader2 className="w-4 h-4 animate-spin" />
  166. {t('settings.pipelines.loading', 'Loading pipelines…')}
  167. </div>
  168. )}
  169. {error && (
  170. <div className="text-sm text-red-700 dark:text-red-400">
  171. {t('settings.pipelines.loadError', 'Could not load pipelines.')}
  172. </div>
  173. )}
  174. {/* Search + target-type filter. Only render when there are pipelines
  175. to filter; the empty-state hint reads better without controls. */}
  176. {!isLoading && !error && allPipelines.length > 0 && (
  177. <div className="flex flex-wrap items-center gap-2 mb-3">
  178. <div className="relative flex-1 min-w-[12rem]">
  179. <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray pointer-events-none" />
  180. <input
  181. type="search"
  182. value={searchTerm}
  183. onChange={(e) => setSearchTerm(e.target.value)}
  184. placeholder={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')}
  185. aria-label={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')}
  186. className="w-full pl-7 pr-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  187. />
  188. </div>
  189. <select
  190. value={targetFilter}
  191. onChange={(e) => setTargetFilter(e.target.value)}
  192. aria-label={t('settings.pipelines.filterTarget', 'Filter by target')}
  193. className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  194. >
  195. <option value="">
  196. {t('settings.pipelines.filter.all', 'All targets')}
  197. </option>
  198. {targetOptions.printers.length > 0 && (
  199. <optgroup label={t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}>
  200. {targetOptions.printers.map((p) => (
  201. <option key={`p-${p.id}`} value={`p:${p.id}`}>
  202. {p.name}
  203. </option>
  204. ))}
  205. </optgroup>
  206. )}
  207. {targetOptions.classes.length > 0 && (
  208. <optgroup label={t('settings.pipelines.field.targetKindClass', 'Printer class')}>
  209. {targetOptions.classes.map((c) => (
  210. <option key={`c-${c}`} value={`c:${c}`}>
  211. {t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: c })}
  212. </option>
  213. ))}
  214. </optgroup>
  215. )}
  216. {targetOptions.anyWithoutTarget && (
  217. <option value="none">
  218. {t('settings.pipelines.filter.noTarget', 'No target set')}
  219. </option>
  220. )}
  221. </select>
  222. {(searchTerm || targetFilter) && (
  223. <span className="text-xs text-bambu-gray">
  224. {t('settings.pipelines.filter.count', '{{shown}} / {{total}}', {
  225. shown: pipelines.length,
  226. total: allPipelines.length,
  227. })}
  228. </span>
  229. )}
  230. </div>
  231. )}
  232. {!isLoading && !error && allPipelines.length === 0 && (
  233. <div className="text-sm text-bambu-gray space-y-2">
  234. <p>{t('settings.pipelines.empty.title', 'No pipelines yet.')}</p>
  235. <p>
  236. {t(
  237. 'settings.pipelines.empty.howto',
  238. 'Open the Slice dialog for any file, pick your printer / process / filaments / bed type, then click "Save as pipeline". Your saved pipelines will appear here.',
  239. )}
  240. </p>
  241. </div>
  242. )}
  243. {!isLoading && !error && allPipelines.length > 0 && pipelines.length === 0 && (
  244. <p className="text-sm text-bambu-gray">
  245. {t('settings.pipelines.filter.noMatches', 'No pipelines match the current filters.')}
  246. </p>
  247. )}
  248. {!isLoading && !error && pipelines.length > 0 && (
  249. <div className="space-y-2">
  250. {pipelines.map((p) => (
  251. <PipelineRow
  252. key={p.id}
  253. pipeline={p}
  254. presets={presets}
  255. printers={printers ?? []}
  256. onSave={(payload) => updateMutation.mutate({ id: p.id, ...payload })}
  257. onDelete={() => {
  258. if (confirm(t('settings.pipelines.confirmDelete', 'Delete this pipeline? This cannot be undone.'))) {
  259. deleteMutation.mutate(p.id);
  260. }
  261. }}
  262. saving={updateMutation.isPending}
  263. deleting={deleteMutation.isPending}
  264. />
  265. ))}
  266. </div>
  267. )}
  268. </CardContent>
  269. </Card>
  270. );
  271. }
  272. function PipelineRow({
  273. pipeline,
  274. presets,
  275. printers,
  276. onSave,
  277. onDelete,
  278. saving,
  279. deleting,
  280. }: {
  281. pipeline: SlicerPipeline;
  282. presets: UnifiedPresetsResponse | undefined;
  283. printers: PrinterType[];
  284. onSave: (payload: {
  285. name?: string;
  286. description?: string | null;
  287. target_printer_id?: number | null;
  288. target_kind?: 'specific_printer' | 'printer_class';
  289. target_model_class?: string | null;
  290. fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
  291. }) => void;
  292. onDelete: () => void;
  293. saving: boolean;
  294. deleting: boolean;
  295. }) {
  296. const { t } = useTranslation();
  297. const [editing, setEditing] = useState(false);
  298. const [draftName, setDraftName] = useState(pipeline.name);
  299. const [draftDescription, setDraftDescription] = useState(pipeline.description ?? '');
  300. const [draftTargetPrinterId, setDraftTargetPrinterId] = useState<number | null>(
  301. pipeline.target_printer_id,
  302. );
  303. // PR C: target kind, model class, and fanout strategy.
  304. const [draftTargetKind, setDraftTargetKind] = useState<'specific_printer' | 'printer_class'>(
  305. pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer',
  306. );
  307. const [draftTargetModelClass, setDraftTargetModelClass] = useState<string>(
  308. pipeline.target_model_class ?? '',
  309. );
  310. const [draftFanout, setDraftFanout] = useState<'max_parallel' | 'fill_one_first' | 'round_robin'>(
  311. pipeline.fanout_strategy ?? 'max_parallel',
  312. );
  313. // Installed model classes — derived from the loaded printers list so the
  314. // dropdown only offers models the user actually has. Same data the row
  315. // header uses, no second fetch.
  316. const installedModels = Array.from(
  317. new Set(printers.map((p) => p.model).filter((m): m is string => !!m)),
  318. ).sort();
  319. // Recent runs for the inline last-run summary. ``enabled: editing === false``
  320. // avoids re-querying every keystroke while the editor is open.
  321. const { data: runsList } = useQuery({
  322. queryKey: ['pipeline-runs', pipeline.id],
  323. queryFn: () => api.listPipelineRuns(pipeline.id, 1),
  324. enabled: !editing,
  325. refetchInterval: 15_000,
  326. });
  327. const lastRun: PipelineRun | undefined = runsList?.runs?.[0];
  328. const printerName = resolveName(presets, 'printer', pipeline.printer_preset);
  329. const processName = resolveName(presets, 'process', pipeline.process_preset);
  330. const filamentResolutions = pipeline.filament_presets.map((f) => resolveName(presets, 'filament', f));
  331. // Collapse identical filaments into a single "All N slots" line — most
  332. // production pipelines load the same filament into every AMS slot, and
  333. // listing the same line three times is just noise. Compares raw preset
  334. // refs (source + id) rather than resolved names so the dedup is correct
  335. // even when ``presets`` hasn't loaded yet.
  336. const filamentsAllIdentical =
  337. pipeline.filament_presets.length > 1 &&
  338. pipeline.filament_presets.every(
  339. (f) =>
  340. f.source === pipeline.filament_presets[0].source &&
  341. f.id === pipeline.filament_presets[0].id,
  342. );
  343. const hasStaleRef =
  344. presets !== undefined &&
  345. (printerName === null || processName === null || filamentResolutions.some((n) => n === null));
  346. const targetPrinter = pipeline.target_printer_id
  347. ? printers.find((p) => p.id === pipeline.target_printer_id)
  348. : undefined;
  349. const isClassTargeting = pipeline.target_kind === 'printer_class';
  350. const needsTarget = isClassTargeting
  351. ? !pipeline.target_model_class
  352. : pipeline.target_printer_id === null;
  353. const handleSave = () => {
  354. const trimmedName = draftName.trim();
  355. if (!trimmedName) return;
  356. onSave({
  357. name: trimmedName,
  358. description: draftDescription.trim() || null,
  359. target_kind: draftTargetKind,
  360. // Backend treats 0 as "clear"; null in TS maps to that intent.
  361. target_printer_id:
  362. draftTargetKind === 'specific_printer' ? (draftTargetPrinterId ?? 0) : 0,
  363. target_model_class:
  364. draftTargetKind === 'printer_class' ? (draftTargetModelClass || null) : null,
  365. fanout_strategy: draftFanout,
  366. });
  367. setEditing(false);
  368. };
  369. const handleCancel = () => {
  370. setDraftName(pipeline.name);
  371. setDraftDescription(pipeline.description ?? '');
  372. setDraftTargetPrinterId(pipeline.target_printer_id);
  373. setDraftTargetKind(pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer');
  374. setDraftTargetModelClass(pipeline.target_model_class ?? '');
  375. setDraftFanout(pipeline.fanout_strategy ?? 'max_parallel');
  376. setEditing(false);
  377. };
  378. return (
  379. <div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 px-3 py-2">
  380. <div className="flex items-start justify-between gap-3">
  381. <div className="min-w-0 flex-1">
  382. {editing ? (
  383. <div className="space-y-2">
  384. <input
  385. value={draftName}
  386. onChange={(e) => setDraftName(e.target.value)}
  387. aria-label={t('settings.pipelines.field.name', 'Pipeline name')}
  388. placeholder={t('settings.pipelines.field.name', 'Pipeline name')}
  389. className="w-full px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  390. />
  391. <textarea
  392. value={draftDescription}
  393. onChange={(e) => setDraftDescription(e.target.value)}
  394. aria-label={t('settings.pipelines.field.description', 'Description')}
  395. placeholder={t('settings.pipelines.field.description', 'Description')}
  396. rows={2}
  397. className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  398. />
  399. {/* PR C — target-kind radio (Specific printer / Printer class)
  400. drives whether the printer dropdown or the class picker is
  401. active. Both fields are kept on state so toggling back and
  402. forth doesn't lose the user's previous pick. */}
  403. <div>
  404. <label className="text-xs text-bambu-gray block mb-1">
  405. {t('settings.pipelines.field.targetKind', 'Target type')}
  406. </label>
  407. <div className="flex gap-3 text-xs">
  408. <label className="flex items-center gap-1 text-white">
  409. <input
  410. type="radio"
  411. name={`target-kind-${pipeline.id}`}
  412. value="specific_printer"
  413. checked={draftTargetKind === 'specific_printer'}
  414. onChange={() => setDraftTargetKind('specific_printer')}
  415. aria-label={t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}
  416. />
  417. {t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}
  418. </label>
  419. <label className="flex items-center gap-1 text-white">
  420. <input
  421. type="radio"
  422. name={`target-kind-${pipeline.id}`}
  423. value="printer_class"
  424. checked={draftTargetKind === 'printer_class'}
  425. onChange={() => setDraftTargetKind('printer_class')}
  426. aria-label={t('settings.pipelines.field.targetKindClass', 'Printer class')}
  427. />
  428. {t('settings.pipelines.field.targetKindClass', 'Printer class')}
  429. </label>
  430. </div>
  431. </div>
  432. {draftTargetKind === 'specific_printer' ? (
  433. <div>
  434. <label className="text-xs text-bambu-gray block mb-1">
  435. {t('settings.pipelines.field.targetPrinter', 'Target printer')}
  436. </label>
  437. <select
  438. value={draftTargetPrinterId ?? ''}
  439. onChange={(e) =>
  440. setDraftTargetPrinterId(e.target.value ? parseInt(e.target.value, 10) : null)
  441. }
  442. aria-label={t('settings.pipelines.field.targetPrinter', 'Target printer')}
  443. className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  444. >
  445. <option value="">
  446. {t('settings.pipelines.field.noTarget', '— No target —')}
  447. </option>
  448. {printers.map((p) => (
  449. <option key={p.id} value={p.id}>
  450. {p.name}
  451. </option>
  452. ))}
  453. </select>
  454. </div>
  455. ) : (
  456. <div className="space-y-2">
  457. <div>
  458. <label className="text-xs text-bambu-gray block mb-1">
  459. {t('settings.pipelines.field.targetModelClass', 'Printer model')}
  460. </label>
  461. <select
  462. value={draftTargetModelClass}
  463. onChange={(e) => setDraftTargetModelClass(e.target.value)}
  464. aria-label={t('settings.pipelines.field.targetModelClass', 'Printer model')}
  465. className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  466. >
  467. <option value="">
  468. {t('settings.pipelines.field.noTarget', '— No target —')}
  469. </option>
  470. {installedModels.map((m) => (
  471. <option key={m} value={m}>
  472. {m}
  473. </option>
  474. ))}
  475. </select>
  476. </div>
  477. <div>
  478. <label className="text-xs text-bambu-gray block mb-1">
  479. {t('settings.pipelines.field.fanoutStrategy', 'Fanout strategy')}
  480. </label>
  481. <select
  482. value={draftFanout}
  483. onChange={(e) =>
  484. setDraftFanout(e.target.value as 'max_parallel' | 'fill_one_first' | 'round_robin')
  485. }
  486. aria-label={t('settings.pipelines.field.fanoutStrategy', 'Fanout strategy')}
  487. className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  488. >
  489. <option value="max_parallel">
  490. {t('settings.pipelines.field.fanout.max_parallel', 'Max parallel — distribute across any idle matching printer')}
  491. </option>
  492. <option value="round_robin">
  493. {t('settings.pipelines.field.fanout.round_robin', 'Round robin — cycle through eligible printers')}
  494. </option>
  495. <option value="fill_one_first">
  496. {t('settings.pipelines.field.fanout.fill_one_first', 'Fill one first — pin all copies to one printer')}
  497. </option>
  498. </select>
  499. </div>
  500. </div>
  501. )}
  502. </div>
  503. ) : (
  504. <>
  505. {/* Header: name + inline target chip (PR C polish). The target
  506. context — specific printer name OR class+strategy — is the
  507. thing the operator most needs to read at a glance, so it
  508. rides up here next to the title instead of buried below. */}
  509. <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
  510. <h4 className="text-sm font-medium text-white truncate">{pipeline.name}</h4>
  511. <span
  512. className={`text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1 ${
  513. needsTarget
  514. ? 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400'
  515. : 'bg-bambu-dark-tertiary text-bambu-gray'
  516. }`}
  517. >
  518. <PrinterIcon className="w-3 h-3" />
  519. {needsTarget ? (
  520. t('settings.pipelines.noTargetHint', 'Set a target printer to run this')
  521. ) : isClassTargeting ? (
  522. <>
  523. {t('library.runWithPipeline.classTarget', 'Any {{model}}', {
  524. model: pipeline.target_model_class,
  525. })}
  526. {pipeline.fanout_strategy && (
  527. <span className="text-bambu-gray/60">
  528. {' · '}
  529. {t(
  530. `settings.pipelines.field.fanoutShort.${pipeline.fanout_strategy}`,
  531. pipeline.fanout_strategy,
  532. )}
  533. </span>
  534. )}
  535. </>
  536. ) : (
  537. targetPrinter?.name ?? ''
  538. )}
  539. </span>
  540. </div>
  541. {pipeline.description && (
  542. <p className="text-xs text-bambu-gray mt-0.5">{pipeline.description}</p>
  543. )}
  544. </>
  545. )}
  546. </div>
  547. <div className="flex items-center gap-1 flex-shrink-0">
  548. {editing ? (
  549. <>
  550. <button
  551. onClick={handleSave}
  552. disabled={saving || !draftName.trim()}
  553. aria-label={t('settings.pipelines.action.save', 'Save')}
  554. className="p-1.5 text-bambu-green hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
  555. >
  556. <Check className="w-4 h-4" />
  557. </button>
  558. <button
  559. onClick={handleCancel}
  560. aria-label={t('settings.pipelines.action.cancel', 'Cancel')}
  561. className="p-1.5 text-bambu-gray hover:bg-bambu-dark-tertiary rounded"
  562. >
  563. <X className="w-4 h-4" />
  564. </button>
  565. </>
  566. ) : (
  567. <>
  568. <button
  569. onClick={() => setEditing(true)}
  570. aria-label={t('settings.pipelines.action.rename', 'Rename')}
  571. className="p-1.5 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded"
  572. >
  573. <Edit2 className="w-4 h-4" />
  574. </button>
  575. <button
  576. onClick={onDelete}
  577. disabled={deleting}
  578. aria-label={t('settings.pipelines.action.delete', 'Delete')}
  579. className="p-1.5 text-bambu-gray hover:text-red-600 dark:hover:text-red-400 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
  580. >
  581. <Trash2 className="w-4 h-4" />
  582. </button>
  583. </>
  584. )}
  585. </div>
  586. </div>
  587. {!editing && (
  588. <div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 text-xs">
  589. {/* Profiles group — printer / process / bed. These travel together
  590. because they describe the slicer profile bundle that produces a
  591. single gcode. The full preset name (including the BambuStudio
  592. ``@BBL <model>`` suffix) is shown verbatim so the user can match
  593. it 1:1 against what they see in the slicer. */}
  594. <div className="space-y-0.5">
  595. <div className="text-[10px] uppercase tracking-wide text-bambu-gray/60">
  596. {t('settings.pipelines.group.profiles', 'Profiles')}
  597. </div>
  598. <PresetLine
  599. label={t('settings.pipelines.slot.printer', 'Printer')}
  600. ref={pipeline.printer_preset}
  601. name={printerName}
  602. />
  603. <PresetLine
  604. label={t('settings.pipelines.slot.process', 'Process')}
  605. ref={pipeline.process_preset}
  606. name={processName}
  607. />
  608. {pipeline.bed_type && (
  609. <div className="text-bambu-gray">
  610. <span className="font-medium text-bambu-gray/80">
  611. {t('settings.pipelines.slot.bed', 'Bed')}:
  612. </span>{' '}
  613. <span className="text-white">{pipeline.bed_type}</span>
  614. </div>
  615. )}
  616. </div>
  617. {/* Filaments group — one per AMS slot. When every slot is the same
  618. filament (the common single-color production-batch case) we
  619. collapse them into a single ``All 4 slots: PLA Basic`` line. */}
  620. <div className="space-y-0.5">
  621. <div className="text-[10px] uppercase tracking-wide text-bambu-gray/60">
  622. {t('settings.pipelines.group.filaments', 'Filaments')}
  623. {pipeline.filament_presets.length > 1 && (
  624. <span className="text-bambu-gray/60 normal-case ml-1">
  625. ({pipeline.filament_presets.length})
  626. </span>
  627. )}
  628. </div>
  629. {filamentsAllIdentical ? (
  630. <PresetLine
  631. label={t('settings.pipelines.slot.filamentAll', 'All {{n}} slots', {
  632. n: pipeline.filament_presets.length,
  633. })}
  634. ref={pipeline.filament_presets[0]}
  635. name={filamentResolutions[0]}
  636. />
  637. ) : (
  638. pipeline.filament_presets.map((f, i) => (
  639. <PresetLine
  640. key={i}
  641. label={
  642. pipeline.filament_presets.length > 1
  643. ? t('settings.pipelines.slot.filamentN', 'Filament {{n}}', { n: i + 1 })
  644. : t('settings.pipelines.slot.filament', 'Filament')
  645. }
  646. ref={f}
  647. name={filamentResolutions[i]}
  648. />
  649. ))
  650. )}
  651. </div>
  652. </div>
  653. )}
  654. {!editing && lastRun && (
  655. <div className="mt-1.5 text-xs text-bambu-gray flex items-center gap-1">
  656. <span className="font-medium text-bambu-gray/80">
  657. {t('settings.pipelines.runs.lastRun', 'Last run')}:
  658. </span>{' '}
  659. <RunStatusBadge status={lastRun.status} />
  660. {lastRun.created_at && (
  661. <span className="text-bambu-gray/60">
  662. · {new Date(lastRun.created_at).toLocaleString()}
  663. </span>
  664. )}
  665. </div>
  666. )}
  667. {needsTarget && !editing && (
  668. <div className="mt-2 flex items-center gap-1.5 text-xs text-amber-700 dark:text-amber-400">
  669. <AlertTriangle className="w-3.5 h-3.5" />
  670. {t(
  671. 'settings.pipelines.noTargetWarning',
  672. 'Set a target printer before running this pipeline.',
  673. )}
  674. </div>
  675. )}
  676. {hasStaleRef && !editing && (
  677. <div className="mt-2 flex items-center gap-1.5 text-xs text-amber-700 dark:text-amber-400">
  678. <AlertTriangle className="w-3.5 h-3.5" />
  679. {t(
  680. 'settings.pipelines.staleWarning',
  681. 'One or more referenced presets no longer exist. Re-save this pipeline from the Slice dialog to fix.',
  682. )}
  683. </div>
  684. )}
  685. </div>
  686. );
  687. }
  688. function RunStatusBadge({ status }: { status: PipelineRun['status'] }) {
  689. const { t } = useTranslation();
  690. const colourClass: Record<PipelineRun['status'], string> = {
  691. queued: 'text-bambu-gray',
  692. slicing: 'text-blue-700 dark:text-blue-400',
  693. dispatching: 'text-blue-700 dark:text-blue-400',
  694. in_progress: 'text-bambu-green',
  695. completed: 'text-bambu-green',
  696. failed: 'text-red-700 dark:text-red-400',
  697. partial_failure: 'text-amber-700 dark:text-amber-400',
  698. cancelled: 'text-bambu-gray',
  699. };
  700. return (
  701. <span className={colourClass[status]}>
  702. {t(`settings.pipelines.runs.status.${status}`, status)}
  703. </span>
  704. );
  705. }
  706. function PresetLine({
  707. label,
  708. ref,
  709. name,
  710. }: {
  711. label: string;
  712. ref: PresetRef;
  713. name: string | null;
  714. }) {
  715. return (
  716. <div className="text-bambu-gray truncate">
  717. <span className="font-medium text-bambu-gray/80">{label}:</span>{' '}
  718. {name ? (
  719. <span className="text-white">{name}</span>
  720. ) : (
  721. <span className="text-amber-700 dark:text-amber-400">[{SOURCE_LABEL[ref.source]} #{ref.id}]</span>
  722. )}
  723. </div>
  724. );
  725. }