PipelineRunsPage.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. import { useEffect, useRef, useState, type ReactNode } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import {
  5. Check,
  6. ChevronDown,
  7. ChevronRight,
  8. FileText,
  9. Filter,
  10. Loader2,
  11. Printer as PrinterIcon,
  12. RefreshCw,
  13. RotateCcw,
  14. Trash2,
  15. Workflow,
  16. X,
  17. } from 'lucide-react';
  18. import { api, type PipelineRun, type Printer, type SlicerPipeline } from '../api/client';
  19. import { useToast } from '../contexts/ToastContext';
  20. type DropdownOption = { value: string; label: string; group?: string };
  21. const STATUSES = [
  22. '',
  23. 'queued',
  24. 'slicing',
  25. 'dispatching',
  26. 'in_progress',
  27. 'completed',
  28. 'partial_failure',
  29. 'failed',
  30. 'cancelled',
  31. ] as const;
  32. const PAGE_LIMIT = 25;
  33. // Dashboard for Slicer Pipeline runs (#1425 PR C).
  34. // Renders the content of the Pipelines tab on the Print Queue page. Lists
  35. // every run across every pipeline with status + pipeline filters and
  36. // pagination; each row expands to show per-copy status; in-flight runs get a
  37. // Cancel button, partial-failure runs get a Retry-failed button. Designed to
  38. // embed inside QueuePage's tab strip — no page wrapper, no top-level title
  39. // (the tab strip provides both).
  40. export function PipelineRunsView() {
  41. const { t } = useTranslation();
  42. const queryClient = useQueryClient();
  43. const { showToast } = useToast();
  44. const [statusFilter, setStatusFilter] = useState<string>('');
  45. const [pipelineFilter, setPipelineFilter] = useState<number | null>(null);
  46. // Target filter — same encoded shape as SlicerPipelinesPanel: '' = all,
  47. // 'p:<printer_id>' = specific printer, 'c:<model_class>' = printer class.
  48. const [targetFilter, setTargetFilter] = useState<string>('');
  49. const [offset, setOffset] = useState(0);
  50. const [expanded, setExpanded] = useState<Set<number>>(new Set());
  51. const [showClearConfirm, setShowClearConfirm] = useState(false);
  52. const { data: pipelines } = useQuery({
  53. queryKey: ['slicer-pipelines'],
  54. queryFn: () => api.listSlicerPipelines(),
  55. });
  56. // Used to resolve target_printer_id → printer name on each row so
  57. // specific-printer runs show "H2D #1" instead of just an id.
  58. const { data: printers } = useQuery({
  59. queryKey: ['printers'],
  60. queryFn: () => api.getPrinters(),
  61. });
  62. const printersById: Record<number, Printer> = (printers ?? []).reduce(
  63. (acc, p) => {
  64. acc[p.id] = p;
  65. return acc;
  66. },
  67. {} as Record<number, Printer>,
  68. );
  69. const targetPrinterId = targetFilter.startsWith('p:')
  70. ? parseInt(targetFilter.slice(2), 10)
  71. : undefined;
  72. const targetModelClass = targetFilter.startsWith('c:')
  73. ? targetFilter.slice(2)
  74. : undefined;
  75. const { data: runsList, isLoading } = useQuery({
  76. queryKey: ['pipeline-runs-all', statusFilter, pipelineFilter, offset, targetFilter],
  77. queryFn: () =>
  78. api.listAllPipelineRuns({
  79. limit: PAGE_LIMIT,
  80. offset,
  81. pipelineId: pipelineFilter ?? undefined,
  82. status: statusFilter || undefined,
  83. targetPrinterId,
  84. targetModelClass,
  85. }),
  86. refetchInterval: 15_000,
  87. });
  88. const cancelMutation = useMutation({
  89. mutationFn: (runId: number) => api.cancelPipelineRun(runId),
  90. onSuccess: () => {
  91. queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
  92. showToast(t('pipelineRuns.toast.cancelled', 'Run cancelled'), 'success');
  93. },
  94. onError: (err: Error) =>
  95. showToast(err.message || t('pipelineRuns.toast.cancelFailed', 'Cancel failed'), 'error'),
  96. });
  97. const retryMutation = useMutation({
  98. mutationFn: (runId: number) => api.retryFailedPipelineRun(runId),
  99. onSuccess: () => {
  100. queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
  101. showToast(t('pipelineRuns.toast.retryStarted', 'Retry started'), 'success');
  102. },
  103. onError: (err: Error) =>
  104. showToast(err.message || t('pipelineRuns.toast.retryFailed', 'Retry failed'), 'error'),
  105. });
  106. const clearMutation = useMutation({
  107. mutationFn: () => api.clearTerminalPipelineRuns(),
  108. onSuccess: (result) => {
  109. queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
  110. setShowClearConfirm(false);
  111. setOffset(0);
  112. showToast(
  113. t('pipelineRuns.toast.cleared', '{{n}} runs cleared', { n: result.deleted }),
  114. 'success',
  115. );
  116. },
  117. onError: (err: Error) =>
  118. showToast(err.message || t('pipelineRuns.toast.clearFailed', 'Clear failed'), 'error'),
  119. });
  120. const runs = runsList?.runs ?? [];
  121. const total = runsList?.total ?? 0;
  122. const pipelinesById: Record<number, SlicerPipeline> = (pipelines?.pipelines ?? []).reduce(
  123. (acc, p) => {
  124. acc[p.id] = p;
  125. return acc;
  126. },
  127. {} as Record<number, SlicerPipeline>,
  128. );
  129. const toggle = (runId: number) => {
  130. setExpanded((prev) => {
  131. const next = new Set(prev);
  132. if (next.has(runId)) next.delete(runId);
  133. else next.add(runId);
  134. return next;
  135. });
  136. };
  137. const hasFilter = !!(statusFilter || pipelineFilter !== null || targetFilter);
  138. // Build the target dropdown's options from the pipelines actually in use.
  139. // Only printers / classes that at least one saved pipeline points at appear
  140. // — keeps the dropdown short and meaningful.
  141. const targetOptions = (() => {
  142. const printerIds = new Set<number>();
  143. const classes = new Set<string>();
  144. for (const p of pipelines?.pipelines ?? []) {
  145. if (p.target_kind === 'printer_class' && p.target_model_class) {
  146. classes.add(p.target_model_class);
  147. } else if (p.target_printer_id) {
  148. printerIds.add(p.target_printer_id);
  149. }
  150. }
  151. return {
  152. printers: (printers ?? []).filter((pr) => printerIds.has(pr.id)),
  153. classes: Array.from(classes).sort(),
  154. };
  155. })();
  156. return (
  157. <div>
  158. {/* Filter row — bare dropdowns with placeholder labels and an inline
  159. refresh button. The previous version had ``Pipeline:`` / ``Status:``
  160. labels next to dropdowns that already declared the same thing, so the
  161. row read as repetitive. */}
  162. <div className="flex flex-wrap items-center gap-2 mb-4 text-sm">
  163. <button
  164. type="button"
  165. onClick={() => queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] })}
  166. aria-label={t('common.refresh', 'Refresh')}
  167. title={t('common.refresh', 'Refresh')}
  168. className="p-1.5 text-bambu-gray hover:text-white border border-bambu-dark-tertiary rounded"
  169. >
  170. <RefreshCw className="w-3.5 h-3.5" />
  171. </button>
  172. <FilterDropdown
  173. icon={<Workflow className="w-3.5 h-3.5 text-bambu-gray" />}
  174. ariaLabel={t('pipelineRuns.filter.pipeline', 'Pipeline')}
  175. value={pipelineFilter === null ? '' : String(pipelineFilter)}
  176. onChange={(v) => {
  177. setOffset(0);
  178. setPipelineFilter(v ? parseInt(v, 10) : null);
  179. }}
  180. options={[
  181. { value: '', label: t('pipelineRuns.filter.allPipelines', 'All pipelines') },
  182. ...((pipelines?.pipelines ?? []).map((p) => ({
  183. value: String(p.id),
  184. label: p.name,
  185. })) as DropdownOption[]),
  186. ]}
  187. />
  188. <FilterDropdown
  189. icon={<Filter className="w-3.5 h-3.5 text-bambu-gray" />}
  190. ariaLabel={t('pipelineRuns.filter.status', 'Status')}
  191. value={statusFilter}
  192. onChange={(v) => {
  193. setOffset(0);
  194. setStatusFilter(v);
  195. }}
  196. options={STATUSES.map((s) => ({
  197. value: s,
  198. label:
  199. s === ''
  200. ? t('pipelineRuns.filter.allStatus', 'All statuses')
  201. : t(`settings.pipelines.runs.status.${s}`, s),
  202. }))}
  203. />
  204. {/* Target filter: built from targets actually in use across saved
  205. pipelines so the dropdown stays short. Mirrors the SlicerPipelinesPanel
  206. target picker. */}
  207. {(targetOptions.printers.length > 0 || targetOptions.classes.length > 0) && (
  208. <FilterDropdown
  209. icon={<PrinterIcon className="w-3.5 h-3.5 text-bambu-gray" />}
  210. ariaLabel={t('pipelineRuns.filter.target', 'Target')}
  211. value={targetFilter}
  212. onChange={(v) => {
  213. setOffset(0);
  214. setTargetFilter(v);
  215. }}
  216. options={[
  217. { value: '', label: t('pipelineRuns.filter.allTargets', 'All targets') },
  218. ...targetOptions.printers.map((p) => ({
  219. value: `p:${p.id}`,
  220. label: p.name,
  221. group: t('settings.pipelines.field.targetKindSpecific', 'Specific printer'),
  222. })),
  223. ...targetOptions.classes.map((c) => ({
  224. value: `c:${c}`,
  225. label: t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: c }),
  226. group: t('settings.pipelines.field.targetKindClass', 'Printer class'),
  227. })),
  228. ]}
  229. />
  230. )}
  231. {hasFilter && (
  232. <button
  233. type="button"
  234. onClick={() => {
  235. setStatusFilter('');
  236. setPipelineFilter(null);
  237. setTargetFilter('');
  238. setOffset(0);
  239. }}
  240. className="text-xs text-bambu-gray hover:text-white"
  241. >
  242. {t('pipelineRuns.filter.clear', 'Clear filters')}
  243. </button>
  244. )}
  245. <div className="ml-auto flex items-center gap-2 text-xs text-bambu-gray">
  246. {!isLoading && total > 0 && (
  247. <span>{t('pipelineRuns.totalCount', '{{n}} run', { n: total, count: total })}</span>
  248. )}
  249. {/* Clear logs — opens a confirmation modal; only enabled when there
  250. are terminal runs that the endpoint could actually delete. */}
  251. <button
  252. type="button"
  253. onClick={() => setShowClearConfirm(true)}
  254. disabled={total === 0}
  255. className="flex items-center gap-1 px-2 py-1 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 rounded disabled:opacity-50 disabled:cursor-not-allowed"
  256. >
  257. <Trash2 className="w-3 h-3" />
  258. {t('pipelineRuns.clearLog', 'Clear log')}
  259. </button>
  260. </div>
  261. </div>
  262. {/* Clear-log confirmation modal. Only deletes terminal runs (completed
  263. / failed / cancelled / partial_failure); in-flight runs are
  264. preserved so an active batch isn't accidentally torched. */}
  265. {showClearConfirm && (
  266. <div
  267. className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
  268. onClick={() => setShowClearConfirm(false)}
  269. role="dialog"
  270. aria-modal="true"
  271. >
  272. <div
  273. className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-2xl w-full max-w-md p-4"
  274. onClick={(e) => e.stopPropagation()}
  275. >
  276. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  277. <Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
  278. {t('pipelineRuns.clearConfirmTitle', 'Clear log?')}
  279. </h3>
  280. <p className="text-sm text-bambu-gray mt-2">
  281. {t(
  282. 'pipelineRuns.clearConfirmBody',
  283. 'Delete every completed, failed, cancelled, and partial-failure pipeline run? In-flight runs are kept. This cannot be undone.',
  284. )}
  285. </p>
  286. <div className="flex items-center justify-end gap-2 mt-4">
  287. <button
  288. type="button"
  289. onClick={() => setShowClearConfirm(false)}
  290. disabled={clearMutation.isPending}
  291. className="px-3 py-1.5 text-sm text-bambu-gray hover:text-white"
  292. >
  293. {t('common.cancel', 'Cancel')}
  294. </button>
  295. <button
  296. type="button"
  297. onClick={() => clearMutation.mutate()}
  298. disabled={clearMutation.isPending}
  299. className="px-3 py-1.5 text-sm bg-red-500 hover:bg-red-600 text-white rounded disabled:opacity-50 flex items-center gap-1"
  300. >
  301. {clearMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
  302. {t('pipelineRuns.clearConfirmAction', 'Clear')}
  303. </button>
  304. </div>
  305. </div>
  306. </div>
  307. )}
  308. {isLoading && (
  309. <div className="flex items-center gap-2 text-bambu-gray">
  310. <Loader2 className="w-4 h-4 animate-spin" />
  311. {t('pipelineRuns.loading', 'Loading…')}
  312. </div>
  313. )}
  314. {!isLoading && runs.length === 0 && (
  315. <p className="text-sm text-bambu-gray">
  316. {hasFilter
  317. ? t('pipelineRuns.filter.noMatches', 'No runs match the current filters.')
  318. : t('pipelineRuns.empty', 'No pipeline runs yet.')}
  319. </p>
  320. )}
  321. {!isLoading && runs.length > 0 && (
  322. <div className="space-y-1.5">
  323. {runs.map((run) => (
  324. <RunRow
  325. key={run.id}
  326. run={run}
  327. pipeline={run.pipeline_id ? pipelinesById[run.pipeline_id] : undefined}
  328. printersById={printersById}
  329. expanded={expanded.has(run.id)}
  330. onToggle={() => toggle(run.id)}
  331. onCancel={() => cancelMutation.mutate(run.id)}
  332. onRetry={() => retryMutation.mutate(run.id)}
  333. cancelling={cancelMutation.isPending}
  334. retrying={retryMutation.isPending}
  335. />
  336. ))}
  337. </div>
  338. )}
  339. {!isLoading && total > PAGE_LIMIT && (
  340. <div className="flex items-center justify-between mt-4 text-sm">
  341. <button
  342. type="button"
  343. onClick={() => setOffset(Math.max(0, offset - PAGE_LIMIT))}
  344. disabled={offset === 0}
  345. className="px-3 py-1.5 rounded border border-bambu-dark-tertiary disabled:opacity-50 text-bambu-gray hover:text-white"
  346. >
  347. {t('common.previous', 'Previous')}
  348. </button>
  349. <span className="text-bambu-gray text-xs">
  350. {t('pipelineRuns.pagination', '{{start}}–{{end}} of {{total}}', {
  351. start: offset + 1,
  352. end: Math.min(offset + PAGE_LIMIT, total),
  353. total,
  354. })}
  355. </span>
  356. <button
  357. type="button"
  358. onClick={() => setOffset(offset + PAGE_LIMIT)}
  359. disabled={offset + PAGE_LIMIT >= total}
  360. className="px-3 py-1.5 rounded border border-bambu-dark-tertiary disabled:opacity-50 text-bambu-gray hover:text-white"
  361. >
  362. {t('common.next', 'Next')}
  363. </button>
  364. </div>
  365. )}
  366. </div>
  367. );
  368. }
  369. function RunRow({
  370. run,
  371. pipeline,
  372. printersById,
  373. expanded,
  374. onToggle,
  375. onCancel,
  376. onRetry,
  377. cancelling,
  378. retrying,
  379. }: {
  380. run: PipelineRun;
  381. pipeline: SlicerPipeline | undefined;
  382. printersById: Record<number, Printer>;
  383. expanded: boolean;
  384. onToggle: () => void;
  385. onCancel: () => void;
  386. onRetry: () => void;
  387. cancelling: boolean;
  388. retrying: boolean;
  389. }) {
  390. const { t } = useTranslation();
  391. const inFlight = ['queued', 'slicing', 'dispatching', 'in_progress'].includes(run.status);
  392. const partial = run.status === 'partial_failure' || run.status === 'failed';
  393. const userCancelled = run.status === 'cancelled' && run.error_message === 'Cancelled by user';
  394. // Target chip — class targeting reads "Any X1C", specific-printer reads the
  395. // printer's actual name (resolved from the printersById map). The chip is
  396. // only rendered when we have a target to show; an unbound pipeline simply
  397. // omits it.
  398. const targetLabel = run.target_kind === 'printer_class' && run.target_model_class
  399. ? t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: run.target_model_class })
  400. : run.target_printer_id && printersById[run.target_printer_id]
  401. ? printersById[run.target_printer_id].name
  402. : null;
  403. return (
  404. <div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 overflow-hidden">
  405. <div className="flex items-center gap-2 px-3 py-2">
  406. <button
  407. type="button"
  408. onClick={onToggle}
  409. aria-label={expanded ? t('common.collapse', 'Collapse') : t('common.expand', 'Expand')}
  410. aria-expanded={expanded}
  411. className="text-bambu-gray hover:text-white flex-shrink-0"
  412. >
  413. {expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
  414. </button>
  415. <div className="flex-1 min-w-0">
  416. {/* Top line: run number · pipeline name · status badge. The status
  417. chip is bigger and more saturated than the previous version so
  418. it actually pops at a glance — finding "what failed" was hard
  419. when all chips read the same washed-out grey. */}
  420. <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
  421. <span className="font-medium text-white">
  422. #{run.id}
  423. </span>
  424. <span className="text-white truncate">
  425. {pipeline?.name ?? run.pipeline_name ?? '—'}
  426. </span>
  427. <RunStatusChip status={run.status} />
  428. {targetLabel && (
  429. <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray flex items-center gap-1">
  430. <PrinterIcon className="w-3 h-3" />
  431. {targetLabel}
  432. </span>
  433. )}
  434. {run.parent_run_id && (
  435. <span className="text-xs text-bambu-gray/70 italic">
  436. {t('pipelineRuns.retryOf', 'retry of #{{n}}', { n: run.parent_run_id })}
  437. </span>
  438. )}
  439. </div>
  440. {/* Second line: source filename — give it its own row so long
  441. titles don't crowd the metadata. */}
  442. {run.source_filename && (
  443. <div className="mt-0.5 flex items-center gap-1.5 text-xs text-bambu-gray min-w-0">
  444. <FileText className="w-3 h-3 flex-shrink-0" />
  445. <span className="truncate" title={run.source_filename}>
  446. {run.source_filename}
  447. </span>
  448. </div>
  449. )}
  450. {/* Third line: timestamp + roll-up counts. Greyed out so the eye
  451. jumps to the title + chip first. */}
  452. <div className="mt-0.5 text-xs text-bambu-gray/70 flex flex-wrap items-center gap-x-2">
  453. <span>{new Date(run.created_at).toLocaleString()}</span>
  454. {run.copies > 1 && (
  455. <>
  456. <span className="text-bambu-gray/40">·</span>
  457. <span>{t('pipelineRuns.copies', '{{n}} copies', { n: run.copies })}</span>
  458. </>
  459. )}
  460. {(run.copies_completed > 0 || run.copies_failed > 0 || run.copies_cancelled > 0) && (
  461. <>
  462. <span className="text-bambu-gray/40">·</span>
  463. <span>
  464. <span className="text-bambu-green">{run.copies_completed}</span>
  465. /{run.copies}
  466. </span>
  467. {run.copies_failed > 0 && (
  468. <>
  469. <span className="text-bambu-gray/40">·</span>
  470. <span className="text-red-700 dark:text-red-400">
  471. {t('pipelineRuns.failedCount', '{{n}} failed', { n: run.copies_failed })}
  472. </span>
  473. </>
  474. )}
  475. </>
  476. )}
  477. </div>
  478. </div>
  479. <div className="flex items-center gap-1 flex-shrink-0">
  480. {inFlight && (
  481. <button
  482. type="button"
  483. onClick={onCancel}
  484. disabled={cancelling}
  485. aria-label={t('common.cancel', 'Cancel')}
  486. className="text-xs px-2 py-1 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 rounded disabled:opacity-50 flex items-center gap-1"
  487. >
  488. <X className="w-3 h-3" />
  489. {t('common.cancel', 'Cancel')}
  490. </button>
  491. )}
  492. {partial && (
  493. <button
  494. type="button"
  495. onClick={onRetry}
  496. disabled={retrying}
  497. aria-label={t('pipelineRuns.retryFailed', 'Retry failed')}
  498. className="text-xs px-2 py-1 text-bambu-green hover:bg-bambu-green/10 rounded disabled:opacity-50 flex items-center gap-1"
  499. >
  500. <RotateCcw className="w-3 h-3" />
  501. {t('pipelineRuns.retryFailed', 'Retry failed')}
  502. </button>
  503. )}
  504. </div>
  505. </div>
  506. {expanded && (
  507. <div className="border-t border-bambu-dark-tertiary px-3 py-2 bg-bambu-dark/30">
  508. <div className="space-y-1.5">
  509. {run.jobs.map((job) => (
  510. <div key={job.id} className="flex items-center gap-2 text-xs">
  511. <span className="text-bambu-gray/60 w-14 flex-shrink-0">
  512. {t('pipelineRuns.copyN', 'Copy {{n}}', { n: job.copy_index + 1 })}
  513. </span>
  514. <JobStatusChip status={job.status} />
  515. {job.assigned_printer_name && (
  516. <span className="text-bambu-gray flex items-center gap-1 truncate">
  517. <PrinterIcon className="w-3 h-3 text-bambu-gray/60" />
  518. <span className="text-white truncate">{job.assigned_printer_name}</span>
  519. </span>
  520. )}
  521. {job.error_message && (
  522. <span className="text-red-700 dark:text-red-400 truncate" title={job.error_message}>
  523. {job.error_message}
  524. </span>
  525. )}
  526. </div>
  527. ))}
  528. </div>
  529. {run.error_message && !userCancelled && (
  530. <div className="text-xs text-red-700 dark:text-red-400 mt-2 pt-2 border-t border-bambu-dark-tertiary">
  531. {run.error_message}
  532. </div>
  533. )}
  534. {userCancelled && (
  535. <div className="text-xs text-bambu-gray/70 mt-2 pt-2 border-t border-bambu-dark-tertiary italic">
  536. {t('pipelineRuns.cancelledByUser', 'Cancelled by user')}
  537. </div>
  538. )}
  539. </div>
  540. )}
  541. </div>
  542. );
  543. }
  544. // High-contrast badges on solid Tailwind palette colours. The previous
  545. // version used ``bg-bambu-gray/25 text-bambu-gray`` etc. — same hue for
  546. // background AND text — which made every chip look washed-out grey
  547. // regardless of state. These use saturated 700-tier backgrounds with bright
  548. // 100-tier text, so each state reads at a glance.
  549. function RunStatusChip({ status }: { status: PipelineRun['status'] }) {
  550. const { t } = useTranslation();
  551. const colours: Record<PipelineRun['status'], string> = {
  552. queued: 'bg-slate-700 text-slate-200',
  553. slicing: 'bg-sky-700 text-sky-100',
  554. dispatching: 'bg-sky-700 text-sky-100',
  555. in_progress: 'bg-emerald-700 text-emerald-100',
  556. completed: 'bg-emerald-700 text-emerald-100',
  557. failed: 'bg-red-700 text-red-100',
  558. partial_failure: 'bg-amber-700 text-amber-100',
  559. cancelled: 'bg-rose-900 text-rose-200',
  560. };
  561. return (
  562. <span className={`px-1.5 py-0.5 rounded text-[10px] uppercase font-semibold tracking-wide whitespace-nowrap ${colours[status]}`}>
  563. {t(`settings.pipelines.runs.status.${status}`, status)}
  564. </span>
  565. );
  566. }
  567. // Custom dropdown — bambu-themed replacement for the native browser
  568. // `<select>` element. Same value/onChange contract; supports optional
  569. // `group` per option to render section headers (replaces <optgroup>).
  570. // Closes on outside click and Escape.
  571. function FilterDropdown({
  572. value,
  573. onChange,
  574. options,
  575. icon,
  576. ariaLabel,
  577. }: {
  578. value: string;
  579. onChange: (value: string) => void;
  580. options: DropdownOption[];
  581. icon: ReactNode;
  582. ariaLabel: string;
  583. }) {
  584. const [open, setOpen] = useState(false);
  585. const ref = useRef<HTMLDivElement>(null);
  586. useEffect(() => {
  587. if (!open) return;
  588. const handleMouseDown = (e: MouseEvent) => {
  589. if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
  590. };
  591. const handleKey = (e: KeyboardEvent) => {
  592. if (e.key === 'Escape') setOpen(false);
  593. };
  594. document.addEventListener('mousedown', handleMouseDown);
  595. document.addEventListener('keydown', handleKey);
  596. return () => {
  597. document.removeEventListener('mousedown', handleMouseDown);
  598. document.removeEventListener('keydown', handleKey);
  599. };
  600. }, [open]);
  601. const selected = options.find((o) => o.value === value);
  602. // Group consecutive options that share the same `group` so the menu can
  603. // render one header per group (mirrors the <optgroup> shape).
  604. const grouped: { group: string | undefined; options: DropdownOption[] }[] = [];
  605. for (const opt of options) {
  606. const last = grouped[grouped.length - 1];
  607. if (last && last.group === opt.group) last.options.push(opt);
  608. else grouped.push({ group: opt.group, options: [opt] });
  609. }
  610. return (
  611. <div ref={ref} className="relative">
  612. <button
  613. type="button"
  614. onClick={() => setOpen((v) => !v)}
  615. aria-label={ariaLabel}
  616. aria-haspopup="listbox"
  617. aria-expanded={open}
  618. className="flex items-center gap-1.5 px-2 py-1 border border-bambu-dark-tertiary rounded bg-bambu-dark/40 text-xs text-white hover:border-bambu-gray/60 focus:outline-none focus:ring-1 focus:ring-bambu-green/40"
  619. >
  620. {icon}
  621. <span className="truncate max-w-[14rem]">{selected?.label ?? ''}</span>
  622. <ChevronDown
  623. className={`w-3 h-3 text-bambu-gray transition-transform ${open ? 'rotate-180' : ''}`}
  624. />
  625. </button>
  626. {open && (
  627. <div
  628. role="listbox"
  629. className="absolute left-0 top-full mt-1 z-30 min-w-full max-h-72 overflow-auto rounded border border-bambu-dark-tertiary bg-bambu-dark-secondary shadow-xl py-1"
  630. >
  631. {grouped.map((g, gi) => (
  632. <div key={gi}>
  633. {g.group && (
  634. <div className="px-2 pt-1.5 pb-0.5 text-[10px] uppercase tracking-wider text-bambu-gray/60">
  635. {g.group}
  636. </div>
  637. )}
  638. {g.options.map((opt) => {
  639. const isSelected = opt.value === value;
  640. return (
  641. <button
  642. key={opt.value}
  643. type="button"
  644. role="option"
  645. aria-selected={isSelected}
  646. onClick={() => {
  647. onChange(opt.value);
  648. setOpen(false);
  649. }}
  650. className={`flex w-full items-center gap-1.5 text-left px-2 py-1 text-xs whitespace-nowrap ${
  651. isSelected
  652. ? 'bg-bambu-dark-tertiary text-white'
  653. : 'text-bambu-gray hover:bg-bambu-dark-tertiary/60 hover:text-white'
  654. }`}
  655. >
  656. <Check
  657. className={`w-3 h-3 flex-shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`}
  658. />
  659. <span className="truncate">{opt.label}</span>
  660. </button>
  661. );
  662. })}
  663. </div>
  664. ))}
  665. </div>
  666. )}
  667. </div>
  668. );
  669. }
  670. function JobStatusChip({ status }: { status: PipelineRun['jobs'][number]['status'] }) {
  671. const { t } = useTranslation();
  672. const colours: Record<PipelineRun['jobs'][number]['status'], string> = {
  673. pending: 'bg-slate-700 text-slate-200',
  674. awaiting_printer: 'bg-sky-700 text-sky-100',
  675. queued: 'bg-sky-700 text-sky-100',
  676. printing: 'bg-emerald-700 text-emerald-100',
  677. completed: 'bg-emerald-700 text-emerald-100',
  678. failed: 'bg-red-700 text-red-100',
  679. cancelled: 'bg-rose-900 text-rose-200',
  680. };
  681. return (
  682. <span className={`px-1.5 py-0.5 rounded text-[10px] uppercase font-semibold tracking-wide ${colours[status]}`}>
  683. {t(`pipelineRuns.jobStatus.${status}`, status)}
  684. </span>
  685. );
  686. }