QueueTimelineView.tsx 20 KB

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