QueueTimelineView.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. import { useState, useMemo, useEffect, useRef } from 'react';
  2. import { ChevronLeft, ChevronRight, Clock, Layers, Printer as PrinterIcon } from 'lucide-react';
  3. import { formatDuration, parseUTCDate } from '../utils/date';
  4. import { compareQueueOrder, queueLaneKey } from '../utils/queueOrder';
  5. import { isBusyOnlyWaitingReason } from '../utils/waitingReason';
  6. import type { PrintQueueItem, Printer } from '../api/client';
  7. import { api } from '../api/client';
  8. import { Button } from './Button';
  9. /** Gantt-style 24h-rolling timeline. One horizontal swimlane per printer
  10. * (plus one per active target_model and one for unassigned items). Each
  11. * pending or printing job is rendered as a colored bar positioned by its
  12. * predicted start time, width = predicted duration. A vertical NOW line
  13. * marks current time. Hover a bar for details, click to edit/stop. */
  14. const HOUR_MS = 60 * 60 * 1000;
  15. const RANGE_HOURS = 24;
  16. const RANGE_MS = RANGE_HOURS * HOUR_MS;
  17. // Minimum bar width — short prints (a few minutes) would otherwise render as
  18. // 1-2px slivers and be unclickable. 32px keeps them at thumb size.
  19. const MIN_BAR_PX = 32;
  20. // Lane height for the bar row + label.
  21. const LANE_BAR_HEIGHT_PX = 40;
  22. interface ScheduleEvent {
  23. item: PrintQueueItem;
  24. estimatedStart: Date;
  25. estimatedEnd: Date;
  26. progress?: number;
  27. type: 'printing' | 'queued';
  28. }
  29. interface QueueTimelineViewProps {
  30. queueItems: PrintQueueItem[];
  31. printers: Printer[];
  32. printerStatuses: Record<number, { progress?: number; remaining_time?: number; state?: string }>;
  33. /** `queue_shortest_first`. The bars chain in dispatch order, and dispatch
  34. * order depends on it, so without it the timeline drew a queue the
  35. * scheduler had no intention of running (#3043). */
  36. sjfEnabled: boolean;
  37. onItemClick: (item: PrintQueueItem) => void;
  38. t: (key: string, options?: Record<string, unknown>) => string;
  39. }
  40. interface LaneDescriptor {
  41. key: string;
  42. label: string;
  43. /** null for model-based / unassigned lanes. */
  44. printerId: number | null;
  45. /** Set for model-based lanes (`Any X1C`). */
  46. targetModel: string | null;
  47. }
  48. function formatHour(date: Date): string {
  49. return date.toLocaleTimeString(undefined, { hour: 'numeric' });
  50. }
  51. function formatTooltipTime(date: Date): string {
  52. return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
  53. }
  54. export function QueueTimelineView({
  55. queueItems,
  56. printers,
  57. printerStatuses,
  58. sjfEnabled,
  59. onItemClick,
  60. t,
  61. }: QueueTimelineViewProps) {
  62. // Tick "now" every minute so the NOW line and ETA labels stay live.
  63. const [now, setNow] = useState(() => new Date());
  64. useEffect(() => {
  65. const interval = setInterval(() => setNow(new Date()), 60_000);
  66. return () => clearInterval(interval);
  67. }, []);
  68. // User can shift the window forward/back in 12h steps. Default = current
  69. // time, so the timeline reads "next 24h from now."
  70. const [windowOffsetMs, setWindowOffsetMs] = useState(0);
  71. // Round the window start down to the previous full hour so the axis ticks
  72. // land on whole hours.
  73. const rangeStartMs = useMemo(() => {
  74. const target = now.getTime() + windowOffsetMs;
  75. return Math.floor(target / HOUR_MS) * HOUR_MS;
  76. }, [now, windowOffsetMs]);
  77. const rangeEndMs = rangeStartMs + RANGE_MS;
  78. const nowMs = now.getTime();
  79. // Build schedule events. Only committed schedules are rendered:
  80. // • currently printing → always
  81. // • pending with explicit scheduled_time → at that time
  82. // • pending ASAP that chain behind a print actually running on the same
  83. // lane → forecast
  84. // Staged (manual_start) and blocked items are not on the timeline because
  85. // they won't auto-dispatch — they'd be misleading bars. "Blocked" is not the
  86. // same as "has a waiting_reason": since #3074 an item queued behind a running
  87. // print carries one too ("Busy: X1C-01"), and that is the chain this view
  88. // exists to forecast. Only a reason that needs the user takes an item off.
  89. // Idle-printer ASAP queues also stay off until something starts on them.
  90. const events = useMemo<ScheduleEvent[]>(() => {
  91. const result: ScheduleEvent[] = [];
  92. const pendingByLaneKey = new Map<string, PrintQueueItem[]>();
  93. // Lanes that have an active print right now — only these qualify for
  94. // ASAP chain forecasting.
  95. const lanesWithActive = new Set<string>();
  96. // Chain-end timestamp per lane (where the next pending item's bar starts).
  97. const chainEndByLane = new Map<string, number>();
  98. for (const item of queueItems) {
  99. if (item.status === 'printing') {
  100. const status = item.printer_id != null ? printerStatuses[item.printer_id] : undefined;
  101. const start = parseUTCDate(item.started_at) || new Date();
  102. let endTime: Date;
  103. if (status?.remaining_time != null && status.remaining_time > 0) {
  104. endTime = new Date(nowMs + status.remaining_time * 60 * 1000);
  105. } else if (item.print_time_seconds) {
  106. const progress = status?.progress || 0;
  107. const remainingFraction = Math.max(0, 1 - progress / 100);
  108. endTime = new Date(nowMs + item.print_time_seconds * remainingFraction * 1000);
  109. } else {
  110. endTime = new Date(nowMs + HOUR_MS);
  111. }
  112. result.push({
  113. item,
  114. estimatedStart: start,
  115. estimatedEnd: endTime,
  116. progress: status?.progress ?? undefined,
  117. type: 'printing',
  118. });
  119. const lk = queueLaneKey(item);
  120. lanesWithActive.add(lk);
  121. chainEndByLane.set(lk, Math.max(chainEndByLane.get(lk) ?? nowMs, endTime.getTime()));
  122. } else if (item.status === 'pending') {
  123. // Skip un-committed pending shapes — staged items and blocked items
  124. // won't auto-dispatch, so a bar would lie. An item merely waiting its
  125. // turn behind a print does auto-dispatch, and is the whole point of the
  126. // chain forecast below.
  127. if (item.manual_start) continue;
  128. if (item.waiting_reason && !isBusyOnlyWaitingReason(item.waiting_reason)) continue;
  129. const lk = queueLaneKey(item);
  130. if (!pendingByLaneKey.has(lk)) pendingByLaneKey.set(lk, []);
  131. pendingByLaneKey.get(lk)!.push(item);
  132. }
  133. }
  134. const sixMonthsFromNow = Date.now() + 180 * 24 * HOUR_MS;
  135. for (const [lk, items] of pendingByLaneKey) {
  136. items.sort((a, b) => compareQueueOrder(a, b, sjfEnabled));
  137. const hasActive = lanesWithActive.has(lk);
  138. // A lane is timelineable when EITHER it has an active print (chain
  139. // forecast off its end) OR its first pending item is scheduled (a
  140. // committed anchor exists). Otherwise every chained ASAP item is just
  141. // a guess — drop the whole lane to keep the view honest.
  142. const firstScheduled = items[0] ? parseUTCDate(items[0].scheduled_time) : null;
  143. const firstScheduledOk = firstScheduled && firstScheduled.getTime() <= sixMonthsFromNow;
  144. if (!hasActive && !firstScheduledOk) continue;
  145. let chainEnd = chainEndByLane.get(lk) ?? nowMs;
  146. for (const item of items) {
  147. const scheduled = parseUTCDate(item.scheduled_time);
  148. if (scheduled && scheduled.getTime() <= sixMonthsFromNow) {
  149. chainEnd = Math.max(chainEnd, scheduled.getTime());
  150. }
  151. const duration = (item.print_time_seconds || 3600) * 1000;
  152. result.push({
  153. item,
  154. estimatedStart: new Date(chainEnd),
  155. estimatedEnd: new Date(chainEnd + duration),
  156. type: 'queued',
  157. });
  158. chainEnd += duration;
  159. }
  160. }
  161. return result;
  162. }, [queueItems, printerStatuses, nowMs, sjfEnabled]);
  163. // Lanes: every printer + every distinct target_model with queue activity
  164. // + an "unassigned" lane if needed. Printers that have NO events queued
  165. // still get a lane so users see idle capacity.
  166. const lanes = useMemo<LaneDescriptor[]>(() => {
  167. const list: LaneDescriptor[] = [];
  168. for (const p of printers) {
  169. list.push({
  170. key: `printer:${p.id}`,
  171. label: p.name,
  172. printerId: p.id,
  173. targetModel: null,
  174. });
  175. }
  176. const modelLanesAdded = new Set<string>();
  177. let hasUnassigned = false;
  178. for (const ev of events) {
  179. if (ev.item.printer_id != null) continue;
  180. if (ev.item.target_model) {
  181. const k = `model:${ev.item.target_model}`;
  182. if (!modelLanesAdded.has(k)) {
  183. modelLanesAdded.add(k);
  184. list.push({
  185. key: k,
  186. label: `${t('queue.filter.any')} ${ev.item.target_model}`,
  187. printerId: null,
  188. targetModel: ev.item.target_model,
  189. });
  190. }
  191. } else {
  192. hasUnassigned = true;
  193. }
  194. }
  195. if (hasUnassigned) {
  196. list.push({
  197. key: 'unassigned',
  198. label: t('queue.filter.unassigned'),
  199. printerId: null,
  200. targetModel: null,
  201. });
  202. }
  203. return list;
  204. }, [printers, events, t]);
  205. const eventsByLane = useMemo(() => {
  206. const map = new Map<string, ScheduleEvent[]>();
  207. for (const ev of events) {
  208. let key: string;
  209. if (ev.item.printer_id != null) key = `printer:${ev.item.printer_id}`;
  210. else if (ev.item.target_model) key = `model:${ev.item.target_model}`;
  211. else key = 'unassigned';
  212. if (!map.has(key)) map.set(key, []);
  213. map.get(key)!.push(ev);
  214. }
  215. return map;
  216. }, [events]);
  217. const hourTicks = useMemo(() => {
  218. const ticks: { ms: number; pct: number; label: string }[] = [];
  219. for (let h = 0; h <= RANGE_HOURS; h += 2) {
  220. const ms = rangeStartMs + h * HOUR_MS;
  221. ticks.push({
  222. ms,
  223. pct: (h / RANGE_HOURS) * 100,
  224. label: formatHour(new Date(ms)),
  225. });
  226. }
  227. return ticks;
  228. }, [rangeStartMs]);
  229. const nowPct = ((nowMs - rangeStartMs) / RANGE_MS) * 100;
  230. const nowInView = nowPct >= 0 && nowPct <= 100;
  231. // Aggregated "all done by" across the entire (un-windowed) event set.
  232. const allDoneBy = useMemo(() => {
  233. let latest = 0;
  234. for (const ev of events) latest = Math.max(latest, ev.estimatedEnd.getTime());
  235. return latest > 0 ? new Date(latest) : null;
  236. }, [events]);
  237. const trackRef = useRef<HTMLDivElement | null>(null);
  238. return (
  239. <div>
  240. {/* Window controls */}
  241. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
  242. <div className="flex items-center gap-2">
  243. <Button
  244. variant="ghost"
  245. size="sm"
  246. onClick={() => setWindowOffsetMs((v) => v - 12 * HOUR_MS)}
  247. className="p-1.5"
  248. title={t('queue.timeline.window.back12h')}
  249. >
  250. <ChevronLeft className="w-4 h-4" />
  251. </Button>
  252. <span className="text-sm font-medium text-white min-w-[180px] text-center">
  253. {new Date(rangeStartMs).toLocaleString(undefined, {
  254. weekday: 'short',
  255. month: 'short',
  256. day: 'numeric',
  257. hour: '2-digit',
  258. minute: '2-digit',
  259. })}
  260. {' → '}
  261. {new Date(rangeEndMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
  262. </span>
  263. <Button
  264. variant="ghost"
  265. size="sm"
  266. onClick={() => setWindowOffsetMs((v) => v + 12 * HOUR_MS)}
  267. className="p-1.5"
  268. title={t('queue.timeline.window.forward12h')}
  269. >
  270. <ChevronRight className="w-4 h-4" />
  271. </Button>
  272. {windowOffsetMs !== 0 && (
  273. <Button
  274. variant="ghost"
  275. size="sm"
  276. onClick={() => setWindowOffsetMs(0)}
  277. className="text-xs text-bambu-green"
  278. >
  279. {t('queue.timeline.window.now')}
  280. </Button>
  281. )}
  282. </div>
  283. {allDoneBy && (
  284. <span className="text-xs text-bambu-gray flex items-center gap-1.5">
  285. <Clock className="w-3.5 h-3.5" />
  286. {t('queue.timeline.allDoneBy', {
  287. time: allDoneBy.toLocaleString(undefined, {
  288. weekday: 'short',
  289. hour: '2-digit',
  290. minute: '2-digit',
  291. }),
  292. })}
  293. </span>
  294. )}
  295. </div>
  296. {/* Empty-state notice when the fleet is idle and no queued item is
  297. committed (no scheduled_time / no active print to chain off).
  298. Without this, users see striped lanes with no bars and assume the
  299. timeline is broken — common confusion from the GHSA-r2qv era. */}
  300. {lanes.length > 0 && events.length === 0 && (
  301. <div className="mb-4 p-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 text-xs text-bambu-gray">
  302. {t('queue.timeline.nothingCommitted')}
  303. </div>
  304. )}
  305. {lanes.length === 0 ? (
  306. <div className="flex flex-col items-center justify-center py-16 text-bambu-gray">
  307. <Layers className="w-12 h-12 mb-3 opacity-30" />
  308. <p className="text-sm">{t('queue.timeline.noData')}</p>
  309. </div>
  310. ) : (
  311. <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden">
  312. {/* Hour axis */}
  313. <div className="flex border-b border-bambu-dark-tertiary">
  314. <div className="w-32 sm:w-40 shrink-0 px-3 py-2 text-xs font-medium text-bambu-gray border-r border-bambu-dark-tertiary">
  315. {t('queue.timeline.printerColumnHeader')}
  316. </div>
  317. <div className="relative flex-1 h-9">
  318. {hourTicks.map((tick) => (
  319. <div
  320. key={tick.ms}
  321. className="absolute top-0 bottom-0 border-l border-bambu-dark-tertiary/40 text-[10px] sm:text-xs text-bambu-gray pl-1 flex items-center"
  322. style={{ left: `${tick.pct}%` }}
  323. >
  324. {tick.label}
  325. </div>
  326. ))}
  327. </div>
  328. </div>
  329. {/* Lanes */}
  330. <div ref={trackRef} className="relative">
  331. {lanes.map((lane) => {
  332. const laneEvents = eventsByLane.get(lane.key) ?? [];
  333. return (
  334. <div key={lane.key} className="flex border-b border-bambu-dark-tertiary/40 last:border-b-0">
  335. <div className="w-32 sm:w-40 shrink-0 px-3 py-3 border-r border-bambu-dark-tertiary flex items-center gap-2">
  336. <PrinterIcon className={`w-3.5 h-3.5 shrink-0 ${
  337. lane.printerId == null && lane.targetModel == null
  338. ? 'text-orange-600 dark:text-orange-400'
  339. : lane.targetModel
  340. ? 'text-blue-600 dark:text-blue-400'
  341. : 'text-bambu-green'
  342. }`} />
  343. <span className="text-sm text-white truncate">{lane.label}</span>
  344. </div>
  345. <div
  346. className="relative flex-1"
  347. style={{ height: LANE_BAR_HEIGHT_PX + 16 }}
  348. >
  349. {/* Hour grid lines */}
  350. {hourTicks.map((tick) => (
  351. <div
  352. key={tick.ms}
  353. className="absolute top-0 bottom-0 border-l border-bambu-dark-tertiary/30"
  354. style={{ left: `${tick.pct}%` }}
  355. />
  356. ))}
  357. {/* Idle background — diagonal stripes hint that the lane
  358. is sittable. Rendered behind bars so they overlay it. */}
  359. <div
  360. className="absolute inset-0 opacity-30"
  361. style={{
  362. backgroundImage:
  363. 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(255,255,255,0.04) 6px, rgba(255,255,255,0.04) 12px)',
  364. }}
  365. aria-hidden
  366. />
  367. {/* Job bars */}
  368. {laneEvents.map((ev) => {
  369. // Clip to the visible window.
  370. const startMs = Math.max(rangeStartMs, ev.estimatedStart.getTime());
  371. const endMs = Math.min(rangeEndMs, ev.estimatedEnd.getTime());
  372. if (endMs <= rangeStartMs || startMs >= rangeEndMs) return null;
  373. const leftPct = ((startMs - rangeStartMs) / RANGE_MS) * 100;
  374. const widthPct = ((endMs - startMs) / RANGE_MS) * 100;
  375. const displayName = ev.item.archive_name
  376. || ev.item.library_file_name
  377. || `#${ev.item.id}`;
  378. const thumbnailUrl = ev.item.archive_thumbnail
  379. ? api.getArchiveThumbnail(ev.item.archive_id!)
  380. : ev.item.library_file_thumbnail
  381. ? api.getLibraryFileThumbnailUrl(ev.item.library_file_id!)
  382. : null;
  383. const isPrinting = ev.type === 'printing';
  384. const isBatched = ev.item.batch_id != null;
  385. const tooltipParts = [
  386. displayName,
  387. `${formatTooltipTime(ev.estimatedStart)} → ${formatTooltipTime(ev.estimatedEnd)}`,
  388. ev.item.print_time_seconds ? formatDuration(ev.item.print_time_seconds) : null,
  389. isPrinting && ev.progress != null ? `${Math.round(ev.progress)}%` : null,
  390. ev.item.batch_name ? `batch: ${ev.item.batch_name}` : null,
  391. ].filter(Boolean).join(' · ');
  392. return (
  393. <button
  394. key={ev.item.id}
  395. onClick={() => onItemClick(ev.item)}
  396. title={tooltipParts}
  397. className={`absolute rounded-md transition-all hover:brightness-110 hover:z-10 overflow-hidden flex items-center gap-1.5 px-1.5 text-left ${
  398. isPrinting
  399. ? 'bg-blue-500/30 border border-blue-400/60'
  400. : isBatched
  401. ? 'bg-cyan-500/20 border border-cyan-400/50'
  402. : 'bg-bambu-green/20 border border-bambu-green/40'
  403. }`}
  404. style={{
  405. left: `${leftPct}%`,
  406. width: `max(${MIN_BAR_PX}px, ${widthPct}%)`,
  407. top: 8,
  408. height: LANE_BAR_HEIGHT_PX,
  409. }}
  410. >
  411. {thumbnailUrl && (
  412. <img
  413. src={thumbnailUrl}
  414. alt=""
  415. className="w-7 h-7 rounded object-cover shrink-0 bg-bambu-dark"
  416. />
  417. )}
  418. <div className="min-w-0 flex-1">
  419. <div className="text-xs text-white font-medium truncate leading-tight">
  420. {displayName}
  421. </div>
  422. <div className="text-[10px] text-bambu-gray truncate leading-tight">
  423. {ev.item.print_time_seconds ? formatDuration(ev.item.print_time_seconds) : ''}
  424. {isPrinting && ev.progress != null ? ` · ${Math.round(ev.progress)}%` : ''}
  425. </div>
  426. </div>
  427. {isPrinting && ev.progress != null && (
  428. <div
  429. className="absolute bottom-0 left-0 h-0.5 bg-blue-300"
  430. style={{ width: `${ev.progress}%` }}
  431. aria-hidden
  432. />
  433. )}
  434. </button>
  435. );
  436. })}
  437. </div>
  438. </div>
  439. );
  440. })}
  441. {/* NOW line — drawn on top of all lanes. Use the same
  442. label-column offset (w-32 sm:w-40) as the lanes so the line
  443. aligns exactly with the time track. */}
  444. {nowInView && (
  445. <div className="absolute top-0 bottom-0 pointer-events-none z-20 flex inset-x-0">
  446. <div className="w-32 sm:w-40 shrink-0" />
  447. <div className="relative flex-1">
  448. <div
  449. className="absolute top-0 bottom-0 w-0.5 bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.6)]"
  450. style={{ left: `${nowPct}%` }}
  451. >
  452. <div className="absolute -top-1 -left-1 w-2.5 h-2.5 bg-red-400 rounded-full" />
  453. </div>
  454. </div>
  455. </div>
  456. )}
  457. </div>
  458. </div>
  459. )}
  460. </div>
  461. );
  462. }