fix(queue): chain the Timeline in the scheduler's order, not queue position (issue #3043)

Turning Shortest Job First on reordered the pending list and the
scheduler, and left the Timeline drawing the pre-SJF queue for good. It
chained each swimlane's bars by queue position alone and was never told
the setting existed -- so the one view whose whole job is to say when
each print will run was the one view answering for an order the
scheduler had no intention of using.

Bars now chain in the order the scheduler will dispatch: jumped items
first, then shortest print time with an unknown duration last, then
position. The starvation guard is visible there too, so a long print
that has finally come up reads as next rather than staying buried behind
every short job on the lane.

Three surfaces claim to show queue order -- the pending list, the "if
started now" ETA, and the Timeline -- and each had its own copy of the
comparator, which is how one of them came to be missing a whole clause.
They now share one, written against the scheduler's ORDER BY.

Grouping came along with it: the pending list folded a model name down
to its first character to build a lane key, so Any X1C and Any X2D both
landed on -88 (as did Any P1S and Any P1P) and the two lanes interleaved
into a single run of rows.

Backend untouched. The scheduler was dispatching correctly the whole
time; only the drawing of it was wrong.
This commit is contained in:
maziggy
2026-09-07 18:56:03 +02:00
parent 069ee8fc87
commit f5cdc86689
8 changed files with 490 additions and 158 deletions
+10 -10
View File
@@ -1,6 +1,7 @@
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 { compareQueueOrder, queueLaneKey } from '../utils/queueOrder';
import type { PrintQueueItem, Printer } from '../api/client';
import { api } from '../api/client';
import { Button } from './Button';
@@ -32,6 +33,10 @@ interface QueueTimelineViewProps {
queueItems: PrintQueueItem[];
printers: Printer[];
printerStatuses: Record<number, { progress?: number; remaining_time?: number; state?: string }>;
/** `queue_shortest_first`. The bars chain in dispatch order, and dispatch
* order depends on it, so without it the timeline drew a queue the
* scheduler had no intention of running (#3043). */
sjfEnabled: boolean;
onItemClick: (item: PrintQueueItem) => void;
t: (key: string, options?: Record<string, unknown>) => string;
}
@@ -57,6 +62,7 @@ export function QueueTimelineView({
queueItems,
printers,
printerStatuses,
sjfEnabled,
onItemClick,
t,
}: QueueTimelineViewProps) {
@@ -97,12 +103,6 @@ export function QueueTimelineView({
// 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;
@@ -124,7 +124,7 @@ export function QueueTimelineView({
progress: status?.progress ?? undefined,
type: 'printing',
});
const lk = laneKeyOf(item);
const lk = queueLaneKey(item);
lanesWithActive.add(lk);
chainEndByLane.set(lk, Math.max(chainEndByLane.get(lk) ?? nowMs, endTime.getTime()));
} else if (item.status === 'pending') {
@@ -132,7 +132,7 @@ export function QueueTimelineView({
// won't auto-dispatch, so a bar would lie.
if (item.manual_start) continue;
if (item.waiting_reason) continue;
const lk = laneKeyOf(item);
const lk = queueLaneKey(item);
if (!pendingByLaneKey.has(lk)) pendingByLaneKey.set(lk, []);
pendingByLaneKey.get(lk)!.push(item);
}
@@ -140,7 +140,7 @@ export function QueueTimelineView({
const sixMonthsFromNow = Date.now() + 180 * 24 * HOUR_MS;
for (const [lk, items] of pendingByLaneKey) {
items.sort((a, b) => a.position - b.position);
items.sort((a, b) => compareQueueOrder(a, b, sjfEnabled));
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
@@ -167,7 +167,7 @@ export function QueueTimelineView({
}
}
return result;
}, [queueItems, printerStatuses, nowMs]);
}, [queueItems, printerStatuses, nowMs, sjfEnabled]);
// Lanes: every printer + every distinct target_model with queue activity
// + an "unassigned" lane if needed. Printers that have NO events queued