SlicerPipelinesPanel.tsx 31 KB

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