| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483 |
- import { useState, useMemo, useEffect, useRef } from 'react';
- import { ChevronLeft, ChevronRight, Clock, Layers, Printer as PrinterIcon } from 'lucide-react';
- import { formatDuration, parseUTCDate } from '../utils/date';
- import type { PrintQueueItem, Printer } from '../api/client';
- import { api } from '../api/client';
- import { Button } from './Button';
- /** Gantt-style 24h-rolling timeline. One horizontal swimlane per printer
- * (plus one per active target_model and one for unassigned items). Each
- * pending or printing job is rendered as a colored bar positioned by its
- * predicted start time, width = predicted duration. A vertical NOW line
- * marks current time. Hover a bar for details, click to edit/stop. */
- const HOUR_MS = 60 * 60 * 1000;
- const RANGE_HOURS = 24;
- const RANGE_MS = RANGE_HOURS * HOUR_MS;
- // Minimum bar width — short prints (a few minutes) would otherwise render as
- // 1-2px slivers and be unclickable. 32px keeps them at thumb size.
- const MIN_BAR_PX = 32;
- // Lane height for the bar row + label.
- const LANE_BAR_HEIGHT_PX = 40;
- interface ScheduleEvent {
- item: PrintQueueItem;
- estimatedStart: Date;
- estimatedEnd: Date;
- progress?: number;
- type: 'printing' | 'queued';
- }
- interface QueueTimelineViewProps {
- queueItems: PrintQueueItem[];
- printers: Printer[];
- printerStatuses: Record<number, { progress?: number; remaining_time?: number; state?: string }>;
- onItemClick: (item: PrintQueueItem) => void;
- t: (key: string, options?: Record<string, unknown>) => string;
- }
- interface LaneDescriptor {
- key: string;
- label: string;
- /** null for model-based / unassigned lanes. */
- printerId: number | null;
- /** Set for model-based lanes (`Any X1C`). */
- targetModel: string | null;
- }
- function formatHour(date: Date): string {
- return date.toLocaleTimeString(undefined, { hour: 'numeric' });
- }
- function formatTooltipTime(date: Date): string {
- return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
- }
- export function QueueTimelineView({
- queueItems,
- printers,
- printerStatuses,
- onItemClick,
- t,
- }: QueueTimelineViewProps) {
- // Tick "now" every minute so the NOW line and ETA labels stay live.
- const [now, setNow] = useState(() => new Date());
- useEffect(() => {
- const interval = setInterval(() => setNow(new Date()), 60_000);
- return () => clearInterval(interval);
- }, []);
- // User can shift the window forward/back in 12h steps. Default = current
- // time, so the timeline reads "next 24h from now."
- const [windowOffsetMs, setWindowOffsetMs] = useState(0);
- // Round the window start down to the previous full hour so the axis ticks
- // land on whole hours.
- const rangeStartMs = useMemo(() => {
- const target = now.getTime() + windowOffsetMs;
- return Math.floor(target / HOUR_MS) * HOUR_MS;
- }, [now, windowOffsetMs]);
- const rangeEndMs = rangeStartMs + RANGE_MS;
- const nowMs = now.getTime();
- // Build schedule events. Only committed schedules are rendered:
- // • currently printing → always
- // • pending with explicit scheduled_time → at that time
- // • pending ASAP that chain behind a print actually running on the same
- // lane → forecast
- // Staged (manual_start) and waiting (waiting_reason) items are not on the
- // timeline because they won't auto-dispatch — they'd be misleading bars.
- // Idle-printer ASAP queues also stay off until something starts on them.
- const events = useMemo<ScheduleEvent[]>(() => {
- const result: ScheduleEvent[] = [];
- const pendingByLaneKey = new Map<string, PrintQueueItem[]>();
- // Lanes that have an active print right now — only these qualify for
- // ASAP chain forecasting.
- const lanesWithActive = new Set<string>();
- // Chain-end timestamp per lane (where the next pending item's bar starts).
- const chainEndByLane = new Map<string, number>();
- const laneKeyOf = (item: PrintQueueItem): string => {
- if (item.printer_id != null) return `printer:${item.printer_id}`;
- if (item.target_model) return `model:${item.target_model}`;
- return 'unassigned';
- };
- for (const item of queueItems) {
- if (item.status === 'printing') {
- const status = item.printer_id != null ? printerStatuses[item.printer_id] : undefined;
- const start = parseUTCDate(item.started_at) || new Date();
- let endTime: Date;
- if (status?.remaining_time != null && status.remaining_time > 0) {
- endTime = new Date(nowMs + status.remaining_time * 60 * 1000);
- } else if (item.print_time_seconds) {
- const progress = status?.progress || 0;
- const remainingFraction = Math.max(0, 1 - progress / 100);
- endTime = new Date(nowMs + item.print_time_seconds * remainingFraction * 1000);
- } else {
- endTime = new Date(nowMs + HOUR_MS);
- }
- result.push({
- item,
- estimatedStart: start,
- estimatedEnd: endTime,
- progress: status?.progress ?? undefined,
- type: 'printing',
- });
- const lk = laneKeyOf(item);
- lanesWithActive.add(lk);
- chainEndByLane.set(lk, Math.max(chainEndByLane.get(lk) ?? nowMs, endTime.getTime()));
- } else if (item.status === 'pending') {
- // Skip un-committed pending shapes — staged items and waiting items
- // won't auto-dispatch, so a bar would lie.
- if (item.manual_start) continue;
- if (item.waiting_reason) continue;
- const lk = laneKeyOf(item);
- if (!pendingByLaneKey.has(lk)) pendingByLaneKey.set(lk, []);
- pendingByLaneKey.get(lk)!.push(item);
- }
- }
- const sixMonthsFromNow = Date.now() + 180 * 24 * HOUR_MS;
- for (const [lk, items] of pendingByLaneKey) {
- items.sort((a, b) => a.position - b.position);
- const hasActive = lanesWithActive.has(lk);
- // A lane is timelineable when EITHER it has an active print (chain
- // forecast off its end) OR its first pending item is scheduled (a
- // committed anchor exists). Otherwise every chained ASAP item is just
- // a guess — drop the whole lane to keep the view honest.
- const firstScheduled = items[0] ? parseUTCDate(items[0].scheduled_time) : null;
- const firstScheduledOk = firstScheduled && firstScheduled.getTime() <= sixMonthsFromNow;
- if (!hasActive && !firstScheduledOk) continue;
- let chainEnd = chainEndByLane.get(lk) ?? nowMs;
- for (const item of items) {
- const scheduled = parseUTCDate(item.scheduled_time);
- if (scheduled && scheduled.getTime() <= sixMonthsFromNow) {
- chainEnd = Math.max(chainEnd, scheduled.getTime());
- }
- const duration = (item.print_time_seconds || 3600) * 1000;
- result.push({
- item,
- estimatedStart: new Date(chainEnd),
- estimatedEnd: new Date(chainEnd + duration),
- type: 'queued',
- });
- chainEnd += duration;
- }
- }
- return result;
- }, [queueItems, printerStatuses, nowMs]);
- // Lanes: every printer + every distinct target_model with queue activity
- // + an "unassigned" lane if needed. Printers that have NO events queued
- // still get a lane so users see idle capacity.
- const lanes = useMemo<LaneDescriptor[]>(() => {
- const list: LaneDescriptor[] = [];
- for (const p of printers) {
- list.push({
- key: `printer:${p.id}`,
- label: p.name,
- printerId: p.id,
- targetModel: null,
- });
- }
- const modelLanesAdded = new Set<string>();
- let hasUnassigned = false;
- for (const ev of events) {
- if (ev.item.printer_id != null) continue;
- if (ev.item.target_model) {
- const k = `model:${ev.item.target_model}`;
- if (!modelLanesAdded.has(k)) {
- modelLanesAdded.add(k);
- list.push({
- key: k,
- label: `${t('queue.filter.any')} ${ev.item.target_model}`,
- printerId: null,
- targetModel: ev.item.target_model,
- });
- }
- } else {
- hasUnassigned = true;
- }
- }
- if (hasUnassigned) {
- list.push({
- key: 'unassigned',
- label: t('queue.filter.unassigned'),
- printerId: null,
- targetModel: null,
- });
- }
- return list;
- }, [printers, events, t]);
- const eventsByLane = useMemo(() => {
- const map = new Map<string, ScheduleEvent[]>();
- for (const ev of events) {
- let key: string;
- if (ev.item.printer_id != null) key = `printer:${ev.item.printer_id}`;
- else if (ev.item.target_model) key = `model:${ev.item.target_model}`;
- else key = 'unassigned';
- if (!map.has(key)) map.set(key, []);
- map.get(key)!.push(ev);
- }
- return map;
- }, [events]);
- const hourTicks = useMemo(() => {
- const ticks: { ms: number; pct: number; label: string }[] = [];
- for (let h = 0; h <= RANGE_HOURS; h += 2) {
- const ms = rangeStartMs + h * HOUR_MS;
- ticks.push({
- ms,
- pct: (h / RANGE_HOURS) * 100,
- label: formatHour(new Date(ms)),
- });
- }
- return ticks;
- }, [rangeStartMs]);
- const nowPct = ((nowMs - rangeStartMs) / RANGE_MS) * 100;
- const nowInView = nowPct >= 0 && nowPct <= 100;
- // Aggregated "all done by" across the entire (un-windowed) event set.
- const allDoneBy = useMemo(() => {
- let latest = 0;
- for (const ev of events) latest = Math.max(latest, ev.estimatedEnd.getTime());
- return latest > 0 ? new Date(latest) : null;
- }, [events]);
- const trackRef = useRef<HTMLDivElement | null>(null);
- return (
- <div>
- {/* Window controls */}
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
- <div className="flex items-center gap-2">
- <Button
- variant="ghost"
- size="sm"
- onClick={() => setWindowOffsetMs((v) => v - 12 * HOUR_MS)}
- className="p-1.5"
- title={t('queue.timeline.window.back12h')}
- >
- <ChevronLeft className="w-4 h-4" />
- </Button>
- <span className="text-sm font-medium text-white min-w-[180px] text-center">
- {new Date(rangeStartMs).toLocaleString(undefined, {
- weekday: 'short',
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- })}
- {' → '}
- {new Date(rangeEndMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
- </span>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => setWindowOffsetMs((v) => v + 12 * HOUR_MS)}
- className="p-1.5"
- title={t('queue.timeline.window.forward12h')}
- >
- <ChevronRight className="w-4 h-4" />
- </Button>
- {windowOffsetMs !== 0 && (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => setWindowOffsetMs(0)}
- className="text-xs text-bambu-green"
- >
- {t('queue.timeline.window.now')}
- </Button>
- )}
- </div>
- {allDoneBy && (
- <span className="text-xs text-bambu-gray flex items-center gap-1.5">
- <Clock className="w-3.5 h-3.5" />
- {t('queue.timeline.allDoneBy', {
- time: allDoneBy.toLocaleString(undefined, {
- weekday: 'short',
- hour: '2-digit',
- minute: '2-digit',
- }),
- })}
- </span>
- )}
- </div>
- {/* Empty-state notice when the fleet is idle and no queued item is
- committed (no scheduled_time / no active print to chain off).
- Without this, users see striped lanes with no bars and assume the
- timeline is broken — common confusion from the GHSA-r2qv era. */}
- {lanes.length > 0 && events.length === 0 && (
- <div className="mb-4 p-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 text-xs text-bambu-gray">
- {t('queue.timeline.nothingCommitted')}
- </div>
- )}
- {lanes.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-16 text-bambu-gray">
- <Layers className="w-12 h-12 mb-3 opacity-30" />
- <p className="text-sm">{t('queue.timeline.noData')}</p>
- </div>
- ) : (
- <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden">
- {/* Hour axis */}
- <div className="flex border-b border-bambu-dark-tertiary">
- <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">
- {t('queue.timeline.printerColumnHeader')}
- </div>
- <div className="relative flex-1 h-9">
- {hourTicks.map((tick) => (
- <div
- key={tick.ms}
- 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"
- style={{ left: `${tick.pct}%` }}
- >
- {tick.label}
- </div>
- ))}
- </div>
- </div>
- {/* Lanes */}
- <div ref={trackRef} className="relative">
- {lanes.map((lane) => {
- const laneEvents = eventsByLane.get(lane.key) ?? [];
- return (
- <div key={lane.key} className="flex border-b border-bambu-dark-tertiary/40 last:border-b-0">
- <div className="w-32 sm:w-40 shrink-0 px-3 py-3 border-r border-bambu-dark-tertiary flex items-center gap-2">
- <PrinterIcon className={`w-3.5 h-3.5 shrink-0 ${
- lane.printerId == null && lane.targetModel == null
- ? 'text-orange-600 dark:text-orange-400'
- : lane.targetModel
- ? 'text-blue-600 dark:text-blue-400'
- : 'text-bambu-green'
- }`} />
- <span className="text-sm text-white truncate">{lane.label}</span>
- </div>
- <div
- className="relative flex-1"
- style={{ height: LANE_BAR_HEIGHT_PX + 16 }}
- >
- {/* Hour grid lines */}
- {hourTicks.map((tick) => (
- <div
- key={tick.ms}
- className="absolute top-0 bottom-0 border-l border-bambu-dark-tertiary/30"
- style={{ left: `${tick.pct}%` }}
- />
- ))}
- {/* Idle background — diagonal stripes hint that the lane
- is sittable. Rendered behind bars so they overlay it. */}
- <div
- className="absolute inset-0 opacity-30"
- style={{
- backgroundImage:
- 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(255,255,255,0.04) 6px, rgba(255,255,255,0.04) 12px)',
- }}
- aria-hidden
- />
- {/* Job bars */}
- {laneEvents.map((ev) => {
- // Clip to the visible window.
- const startMs = Math.max(rangeStartMs, ev.estimatedStart.getTime());
- const endMs = Math.min(rangeEndMs, ev.estimatedEnd.getTime());
- if (endMs <= rangeStartMs || startMs >= rangeEndMs) return null;
- const leftPct = ((startMs - rangeStartMs) / RANGE_MS) * 100;
- const widthPct = ((endMs - startMs) / RANGE_MS) * 100;
- const displayName = ev.item.archive_name
- || ev.item.library_file_name
- || `#${ev.item.id}`;
- const thumbnailUrl = ev.item.archive_thumbnail
- ? api.getArchiveThumbnail(ev.item.archive_id!)
- : ev.item.library_file_thumbnail
- ? api.getLibraryFileThumbnailUrl(ev.item.library_file_id!)
- : null;
- const isPrinting = ev.type === 'printing';
- const isBatched = ev.item.batch_id != null;
- const tooltipParts = [
- displayName,
- `${formatTooltipTime(ev.estimatedStart)} → ${formatTooltipTime(ev.estimatedEnd)}`,
- ev.item.print_time_seconds ? formatDuration(ev.item.print_time_seconds) : null,
- isPrinting && ev.progress != null ? `${Math.round(ev.progress)}%` : null,
- ev.item.batch_name ? `batch: ${ev.item.batch_name}` : null,
- ].filter(Boolean).join(' · ');
- return (
- <button
- key={ev.item.id}
- onClick={() => onItemClick(ev.item)}
- title={tooltipParts}
- 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 ${
- isPrinting
- ? 'bg-blue-500/30 border border-blue-400/60'
- : isBatched
- ? 'bg-cyan-500/20 border border-cyan-400/50'
- : 'bg-bambu-green/20 border border-bambu-green/40'
- }`}
- style={{
- left: `${leftPct}%`,
- width: `max(${MIN_BAR_PX}px, ${widthPct}%)`,
- top: 8,
- height: LANE_BAR_HEIGHT_PX,
- }}
- >
- {thumbnailUrl && (
- <img
- src={thumbnailUrl}
- alt=""
- className="w-7 h-7 rounded object-cover shrink-0 bg-bambu-dark"
- />
- )}
- <div className="min-w-0 flex-1">
- <div className="text-xs text-white font-medium truncate leading-tight">
- {displayName}
- </div>
- <div className="text-[10px] text-bambu-gray truncate leading-tight">
- {ev.item.print_time_seconds ? formatDuration(ev.item.print_time_seconds) : ''}
- {isPrinting && ev.progress != null ? ` · ${Math.round(ev.progress)}%` : ''}
- </div>
- </div>
- {isPrinting && ev.progress != null && (
- <div
- className="absolute bottom-0 left-0 h-0.5 bg-blue-300"
- style={{ width: `${ev.progress}%` }}
- aria-hidden
- />
- )}
- </button>
- );
- })}
- </div>
- </div>
- );
- })}
- {/* NOW line — drawn on top of all lanes. Use the same
- label-column offset (w-32 sm:w-40) as the lanes so the line
- aligns exactly with the time track. */}
- {nowInView && (
- <div className="absolute top-0 bottom-0 pointer-events-none z-20 flex inset-x-0">
- <div className="w-32 sm:w-40 shrink-0" />
- <div className="relative flex-1">
- <div
- className="absolute top-0 bottom-0 w-0.5 bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.6)]"
- style={{ left: `${nowPct}%` }}
- >
- <div className="absolute -top-1 -left-1 w-2.5 h-2.5 bg-red-400 rounded-full" />
- </div>
- </div>
- </div>
- )}
- </div>
- </div>
- )}
- </div>
- );
- }
|