Aller au contenu

Planification d’un cluster GPU

Un entraînement s’est arrêté avec son nœud. Relancez-le sur le cluster avant 15:00 et faites suivre son pipeline, depuis un terminal.

  • Calcul
  • batchd
  • Volets partagés
  • Pilotage au clavier
  • Sélection rectangulaire
  • Multisélection
  • Recherche instantanée
  • Vues enregistrées
  • Dépendances
  • Minimap
  • Thèmes
  • Démo avec données fictives

Mission

0 étapes faites sur 3

Mercredi, 14:05. gpu-a04 a signalé des erreurs ECC à 13:50 et a été retiré du service ; son entraînement, train-vision-7b, est revenu dans la file en priorité haute. Il doit redémarrer avant 15:00.

  1. Démarrez train-vision-7b sur un nœud de classe A avant 15:00. — À faire

    Glissez-le depuis la ligne high du panneau de file directement vers gpu-a03, au-dessus : le dépôt récupère les GPU des deux balayages préemptibles. Les nœuds de classe B le refusent (48 GB). Ou saisissez :mv! train-vision-7b gpu-a03 14:15.

  2. Déplacez son évaluation et son export après l’entraînement. — À faire

    Sur gpu-b02, appuyez sur v et tracez un rectangle autour de eval-vision-7b et export-vision-7b, puis glissez l’un des deux vers la droite, après 20:15. Ou faites un clic droit sur l’évaluation et choisissez l’action en cascade.

  3. Enregistrez cette vue sous le nom triage. — À faire

    Appuyez sur : et saisissez w triage, puis Entrée. La vue apparaît comme fenêtre 1 dans la barre d’état ; :e triage la restaure.

Environ 30 secondes. Les étapes se cochent au fur et à mesure.

Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.

batchd
rk-a24/40gpu-a01[||||||||] 8/8gpu-a02[||||||||] 8/8gpu-a03[········] 0/8gpu-a04[········] 0/8gpu-a05[||||||||] 8/8rk-b24/24gpu-b01[||||||||] 8/8gpu-b02[||||||||] 8/8gpu-b03[||||||||] 8/8rk-l6/16┤ 1:file · 6 en attente · 26 GPU ├high1 tâche · 8 GPUnormal3 tâches · 12 GPUpreemptible2 tâches · 6 GPUtrain-llm-13b 8×A▓▓▓▓░░░░░░ 40% →02:00train-nlp-16 8×A@02:15 10hsweep-lr-24 8×A@12:30 4hfinetune-speech-17 8×A@18:00 5hfinetune-asr-3 4×A▓▓▓▓▓▓░░░░ 58% →17:00distill-nlp-2 4×A▓▓▓░░░░░░░ 30% →19:00train-nlp-18 4×A@17:00 8hsweep-aug-23 4×A@01:45 10htrain-nlp-19 4×A@12:45 10hfinetune-speech-19 4×Aterminée 4h30sweep-aug-26 4×A@19:00 10htrain-nlp-21 4×A@05:45 12heval-robot-5 4×Aterminée 4h45sweep-lr-43 4×Aterminée 5hsweep-lr-44 4×A@14:15 4hsweep-lr-45 4×A@14:30 5hfinetune-speech-8 4×A@20:30 8hfinetune-speech-22 4×A@04:45 8htrain-nlp-23 4×A@04:30 5htrain-nlp-24 4×A@11:00 12htrain-vision-7b 8×Aerreurs ECC 5h50burnin-a04 8×A@18:00 1hpretrain-robot-16 8×A@19:00 10hpretrain-robot-17 8×A@05:30 4hsweep-aug-28 8×A@11:00 8hpretrain-robot-1 8×A▓▓░░░░░░░░ 21% →09:00finetune-speech-24 8×A@09:30 5heval-llm-13b 4×B@02:00 3hsim-robot-22 4×Bterminée 6heval-speech-28 4×B▓▓░░░░░░░░ 17% →15:45eval-vision-15 4×B@17:15 3hsim-robot-23 4×B@21:45 3heval-nlp-23 4×B@01:15 6hsweep-reg-20 4×B@08:15 4heval-speech-29 4×B@15:15 4heval-speech-33 4×B▓▓▓▓▓▓▓▓░░ 77% →15:15eval-vision-17 4×B@16:45 3hsweep-reg-23 4×B@21:15 3heval-vision-18 4×B@05:00 3heval-nlp-25 4×B@08:00 6hsweep-reg-24 4×B@14:30 2heval-nlp-26 4×B@19:30 4heval-vision-7b 4×B@19:00 2hexport-vision-7b 1×B@21:00 30msweep-reg-27 4×Bterminée 5heval-nlp-29 4×B▓▓░░░░░░░░ 19% →16:30eval-vision-21 4×B@02:30 5heval-nlp-30 4×B@08:15 2hsim-robot-26 4×B@13:15 5hsweep-reg-28 4×B@19:45 3hsweep-reg-29 4×Bterminée 4heval-nlp-34 4×B▓▓▓▓▓░░░░░ 53% →15:30eval-speech-36 4×B@02:00 5heval-nlp-35 4×B@07:30 3hsweep-reg-30 4×B@10:30 4heval-vision-24 4×B@19:15 4heval-vision-28 4×Bterminée 4hsim-robot-28 4×B▓▓▓░░░░░░░ 32% →17:30sweep-reg-33 4×B@18:00 4heval-speech-39 4×B@22:30 3heval-nlp-37 4×B@01:30 2heval-vision-29 4×B@03:45 4hsweep-reg-34 4×B@13:00 2heval-speech-40 4×B@15:45 5hsweep-reg-36 4×B▓▓▓▓▓▓░░░░ 64% →16:15eval-speech-44 4×B@17:15 3hsim-robot-29 4×B@21:45 3hsim-robot-30 4×B@01:45 2heval-nlp-40 4×B@04:00 4hsim-robot-31 4×B@12:15 3heval-speech-45 4×B@15:45 6htrain-vision-7b 8×80Gen attente 6htokenize-cc-9 2×24Gen attente 2h50eval-asr-3 2×48Gen attente 2htrain-nlp-41 8×80Gsuspendue 10hsweep-lr-46 4×80Gen attente 4hsweep-lr-47 2×48Gen attente 3h85858380747159647573636573746962818386839480746656696879686060515444Σ cluster%/heureMER 21 OCTJEU 22 OCT10111213141516171819202122230001020304050607080910111213141516171819

Aperçu de la première imageEn direct : le vrai moteur Pro, dans votre navigateurEnviron 30 secondes. Les étapes se cochent au fur et à mesure. Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.

La situation.

batchd est l’ordonnanceur fictif d’un laboratoire d’apprentissage automatique : dix nœuds de huit GPU répartis dans trois racks (classe A de 80 GB pour l’entraînement, classe B de 48 GB pour l’évaluation, classe L de 24 GB pour l’inférence) et une file à trois niveaux de priorité. Les chercheurs soumettent des tâches ; un nœud en exécute plusieurs à la fois tant que ses GPU suffisent, et des balayages d’hyperparamètres préemptibles occupent les créneaux libres jusqu’à ce qu’une tâche plus prioritaire en ait besoin.

Les équipes qui exploitent ces clusters travaillent dans des terminaux. Ce tableau en reprend les codes (panneaux, ligne de commande, jauges en caractères de bloc), mais trace le planning sur une frise chronologique : ce qui occupe chaque nœud maintenant et cette nuit, ce qui attend dans la file, quel pipeline se rompt quand une tâche se déplace. Un glisser-déposer et une commande suivent les mêmes règles.

Ce que vous allez faire

Vous replanifierez un entraînement en échec sur un nœud de classe A en préemptant deux balayages, déplacerez ensemble son évaluation et son export à sa suite, puis enregistrerez le résultat comme vue.

Où s’arrête la bibliothèque et où commence votre application.

SuperScheduler fournit

  • SchedulerPanes (super-scheduler/panes) : les nœuds et la file dans deux panneaux sur une même frise, avec zoom et défilement partagés, séparateur redimensionnable au clavier et déplacements entre panneaux.
  • Un arbre de ressources (racks et nœuds) avec rowHeaderColumns, une ligne de synthèse figée dessinée par onBeforeCellRender et deux lignes d’en-tête temporel écrites par onBeforeTimeHeaderRender.
  • Des événements qui se chevauchent, empilés dans des couloirs (allowEventOverlap, eventMarginBottom), des cartes HTML sur deux lignes, des cellules désactivées pour les nœuds retirés du service, des liens fin-début et un séparateur à l’heure du cluster.
  • La sélection d’événements par clic, Ctrl/⌘+clic et rectangles (rectangleSelectHandling, startRectangle), leur déplacement groupé avec allowMultiMove et la navigation complète au clavier (keyboardMode: 'Full').
  • rows.filter et events.filter derrière la commande :filter et la recherche en direct, getViewState et applyViewState (super-scheduler/views) derrière :w et :e.
  • La minicarte (super-scheduler/minimap) avec vos propres séries et couleurs, les niveaux de zoom, l’historique d’annulation, la carte au survol, la carte de déplacement et un menu contextuel à l’habillage du produit.

Le code de cet exemple décide

  • Les règles : mémoire GPU par classe, GPU libres par nœud avec le calcul dans chaque refus, nœuds retirés du service, pipelines et réservations préemptibles qu’un dépôt plus prioritaire évince.
  • Le langage de commande : un analyseur, la complétion des noms de tâches et de nœuds avec Tab, l’historique, les erreurs au format shell et la confirmation y/n avant de remettre une tâche réservée dans la file.
  • Chaque déplacement, par glisser-déposer ou commande, appliqué comme une seule modification d’état et une seule étape d’annulation, tâches préemptées comprises.
  • Les indicateurs : allocation par heure et par nœud, file, échecs et puissance, ainsi que les séries tracées par la minicarte.
  • Ce qu’une vue enregistrée conserve aussi (recherche et filtre) et la barre d’état tmux qui présente les vues comme des fenêtres.

Le code de cette démo.

Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.

tsx.zip

L’intégration : SchedulerPanes avec deux panneaux, les niveaux de zoom (sans en-têtes dans le panneau de file), rowHeaderColumns, la ligne du cluster figée, les cellules des nœuds retirés du service, les liens construits à partir des pipelines, la carte de déplacement qui affiche la commande exécutée au dépôt, le menu contextuel et onEventMoving / onEventMove, où les règles décident et le dépôt est transmis à l’application.

/**
 * batchd: a GPU cluster and its batch queue as two tmux panes over one timeline. The nodes pane on
 * top (racks and their eight-GPU nodes under a frozen cluster row), the queue pane below (one row
 * per priority class); a job dragged from the queue onto a node is scheduled, one dragged back is
 * requeued.
 *
 * Library: SchedulerPanes (super-scheduler/panes) with cross-pane moves and a splitter, a resource
 * tree with rowHeaderColumns, a frozen summary row painted by onBeforeCellRender, two time header
 * rows written by onBeforeTimeHeaderRender, zoom levels, overlapping events (allowEventOverlap)
 * stacked in lanes, two-line HTML cards, disabled cells for drained nodes, finish-to-start links,
 * a separator at the cluster clock, the full crosshair, rows.filter and events.filter, event
 * selection with Ctrl/⌘+click and rectangles (rectangleSelectHandling) moved together
 * (allowMultiMove), the full keyboard model, undo history, the event hover card and the drag card
 * (super-scheduler/react-render) and a context menu. Application: GPU memory per class, free GPUs
 * per node, drains, pipelines, which preemptible bookings a drop displaces, the queue's priorities
 * and every figure. Those rules live in ./rules; ./paint writes the markup. Every move is applied
 * by the demo as one state change and one undo step (onEventMove cancels the library's own commit).
 */
import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
  type RefObject,
} from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { subscribeScheduler } from 'super-scheduler/hooks'
import {
  SchedulerPanes,
  type SchedulerPane,
  type SchedulerPanesHandle,
} from 'super-scheduler/panes'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { DAY, HOUR, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { KEYS } from '../kit/keys'
import { useDropRefusal } from '../kit/use-drop-refusal'
import { skinClass, skinMenuTheme } from '../registry'
import {
  BOARD,
  SUMMARY_ID,
  nodesPaneHeight,
  readJobFields,
  rowKind,
  type JobEvent,
} from './adapter'
import { ZOOM_IDS, type ZoomId } from './commands'
import { compact as walltimeText } from './glyphs'
import { JobCard } from './job-card'
import {
  INITIAL_VIEW_START,
  NOW,
  NOW_TICKS,
  PLAN,
  PRIORITIES,
  RACKS,
  classOf,
  type GpuClass,
  type Job,
  type NodeId,
  type QueueId,
} from './model'
import {
  cellHeaderHtml,
  clock,
  dayHeaderHtml,
  drainHtml,
  escape,
  jobHtml,
  jobLabel,
  meterHtml,
  nodeHtml,
  queueHtml,
  rackHtml,
  refusalText,
  sparkHtml,
  summaryCellHtml,
  summaryHeaderHtml,
  summaryLegendHtml,
  summaryMetaHtml,
  verdictHtml,
  verdictWarnings,
} from './paint'
import {
  allocation,
  bestFit,
  cascade,
  drainsBetween,
  earliestStart,
  isNode,
  isQueue,
  jobMatches,
  nextQuarter,
  nodeLoad,
  passesFilter,
  priorityOfQueue,
  rackUsage,
  roundQuarter,
  stateOf,
  planMove,
  usageAt,
  walltime,
  type FilterTerms,
  type Issue,
  type MovePlan,
  type MoveTarget,
  type Refusal,
} from './rules'
import type { BatchdText } from './text'
import 'super-scheduler/styles.css'
import '../../scheduler/skin.css'
import './tokens.css'
import './tty.css'

const SLUG = 'compute-cluster'

/** What the board asks the demo to do (context menu, job pane). */
export type JobAction =
  | 'show'
  | 'run'
  | 'hold'
  | 'release'
  | 'requeue'
  | 'cascade'
  | 'prio-high'
  | 'prio-normal'
  | 'prio-preemptible'

/** A drop or a keyboard move: one job, or a selection shifted in time (`shift` ms). */
export interface MoveRequest {
  readonly targets: readonly MoveTarget[]
  readonly shift: number | null
}

/** The live search and the `:filter` terms, handed to events.filter. */
export interface EventFilter {
  readonly query: string
  readonly terms: FilterTerms
}

export interface BatchdSchedulerProps {
  readonly events: JobEvent[]
  /** The same events as jobs, for the rules. */
  readonly jobs: readonly Job[]
  readonly issues: readonly Issue[]
  readonly resources: SuperScheduler.ResourceData[]
  readonly text: BatchdText
  readonly tag: string
  readonly schedulerLocale: string
  /** Total height of the panes, px (the panes need a number). */
  readonly height: number
  /** Phones: the node column only. */
  readonly narrow: boolean
  /** Tablets: no sparkline column. */
  readonly compact: boolean
  readonly initialZoom: ZoomId
  readonly panesRef: RefObject<SchedulerPanesHandle | null>
  readonly history: SchedulerHistory
  readonly eventFilter: EventFilter | null
  readonly classFilter: GpuClass | null
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onMove: (request: MoveRequest) => void
  readonly onRefused: (id: string, message: string) => void
  readonly onSelection: () => void
  readonly onAction: (action: JobAction, id: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
  /** The visible range once scrolling settles (ticks). */
  readonly onViewport: (start: number, end: number) => void
  readonly onPanes: () => void
}

/** Header rows of a zoom level; the queue pane gets none (the nodes pane draws them). */
function zoomLevels(header: boolean): SuperScheduler.ZoomLevel[] {
  const rows = (
    group: SuperScheduler.GroupBy,
    cellFormat: string,
  ): SuperScheduler.TimeHeaderData[] =>
    header
      ? [
          { groupBy: group, height: BOARD.header.group },
          // A declared format tells the level-of-detail planner how wide the label really is.
          { groupBy: 'Cell', height: BOARD.header.cell, format: cellFormat },
        ]
      : []
  return [
    {
      id: '15m',
      properties: {
        scale: 'CellDuration',
        cellDuration: 15,
        cellWidth: BOARD.cellWidth['15m'],
        timeHeaders: rows('Hour', 'mm'),
      },
    },
    {
      id: '1h',
      properties: {
        scale: 'Hour',
        cellWidth: BOARD.cellWidth['1h'],
        timeHeaders: rows('Day', 'HH'),
      },
    },
    {
      id: '6h',
      properties: {
        scale: 'CellDuration',
        cellDuration: 360,
        cellWidth: BOARD.cellWidth['6h'],
        timeHeaders: rows('Day', 'HH'),
      },
    },
  ]
}

const NODE_LEVELS = zoomLevels(true)
const QUEUE_LEVELS = zoomLevels(false)

const percent = (value: number) => `${Math.round(value * 10000) / 100}%`

/**
 * Where a dragged job would land: on a node, on the quarter hour under the pointer and never
 * before the next quarter after the clock; in the queue, at its earliest start (its time there is
 * when it could run, not a choice).
 */
export function landing(
  job: Job,
  place: NodeId | QueueId,
  start: number,
  jobs: readonly Job[],
): number {
  if (isQueue(place)) return earliestStart(job, jobs)
  return Math.max(nextQuarter(NOW_TICKS), roundQuarter(start))
}

/** The command a placement stands for, as the drag card prints it. */
export function commandOf(
  job: Job,
  place: NodeId | QueueId,
  start: number,
  preempts: boolean,
): string {
  if (isQueue(place)) {
    const priority = priorityOfQueue(place)
    return isQueue(job.place) ? `prio ${job.name} ${priority}` : `requeue ${job.name}`
  }
  const time = civil(start).slice(11, 16)
  return `mv${preempts ? '!' : ''} ${job.name} ${place} ${time}`
}

/** A refusal, prefixed with the member's name when a selection member is the one refused. */
function refusalMessage(
  failed: Job,
  reason: Refusal,
  master: Job,
  text: BatchdText,
  tag: string,
): string {
  const sentence = refusalText(failed, reason, text, tag)
  return failed.id === master.id ? sentence : `${failed.name}: ${sentence}`
}

/** A signed shift as the command line writes it: `+1h15`, `-30m`. */
export function shiftText(ms: number): string {
  return `${ms < 0 ? '-' : '+'}${walltimeText(ms)}`
}

/** Registers the open card's `close` so the board can dismiss it. */
function Registered({
  close,
  register,
  children,
}: {
  close: () => void
  register: (close: (() => void) | null) => void
  children: ReactNode
}) {
  useLayoutEffect(() => {
    register(close)
    return () => register(null)
  }, [close, register])
  return children
}

/** The library's eventHover with batchd's `show job` card; kept away while a menu is open. */
function useJobHover(build: (event: SuperScheduler.Event) => ReactNode | null) {
  const current = useRef<(() => void) | null>(null)
  const suppressed = useRef(false)
  const register = useCallback((close: (() => void) | null) => {
    current.current = close
  }, [])
  const closeHover = useCallback(() => current.current?.(), [])
  const suppressHover = useCallback((value: boolean) => {
    suppressed.current = value
    if (value) current.current?.()
  }, [])
  const eventHover = useMemo<NonNullable<SchedulerProps['eventHover']>>(
    () => ({
      delay: 260,
      leaveGrace: 140,
      placement: 'auto',
      glide: true,
      pin: false,
      render: ({ e, close }) => {
        if (suppressed.current) return null
        const node = build(e)
        return node === null ? null : (
          <Registered close={close} register={register}>
            {node}
          </Registered>
        )
      },
    }),
    [build, register],
  )
  return { eventHover, closeHover, suppressHover }
}

export function BatchdScheduler(props: BatchdSchedulerProps) {
  const {
    events,
    jobs,
    issues,
    resources,
    text,
    tag,
    schedulerLocale,
    height,
    narrow,
    compact,
    initialZoom,
    panesRef,
    history,
    eventFilter,
    classFilter,
  } = props
  const {
    onEventsChange,
    onMove,
    onRefused,
    onSelection,
    onAction,
    onZoomLevel,
    onViewport,
    onPanes,
  } = props

  const byId = useMemo(() => new Map(jobs.map((job) => [job.id, job])), [jobs])
  const refusal = useDropRefusal(onRefused)

  // The rules' decision for one frame of a drag, cached per frame.
  const judge = useMemo(() => createJudge(jobs, byId), [jobs, byId])

  // Header labels depend on the level's scale: each settled level gets a fresh hook, so the
  // control labels its headers again after an animated zoom.
  const [level, setLevel] = useState<ZoomId>(initialZoom)
  const timeHeaders = useMemo(() => {
    const cache = new Map<
      string,
      Pick<SuperScheduler.TimeHeaderRenderHeader, 'html' | 'cssClass' | 'areas'>
    >()
    const nowTag = escape(clock(NOW_TICKS, tag))
    const nowTip = `${text.status.clock} ${clock(NOW_TICKS, tag)}`
    const hook = (args: SuperScheduler.SchedulerBeforeTimeHeaderRenderArgs) => {
      const header = args.header
      const start = header.start.getTime()
      const span = header.end.getTime() - start
      const key = `${header.level}|${start}|${span}`
      let painted = cache.get(key)
      if (painted === undefined) {
        const holdsNow = NOW_TICKS >= start && NOW_TICKS < start + span
        if (header.level === 0) {
          // An hour group holding the clock shows the clock tag instead of its own label.
          const label = dayHeaderHtml(start, span, text, tag)
          painted = {
            html: holdsNow && span < DAY ? `<span class="sr-only">${label}</span>` : label,
            cssClass: 'bd-th bd-th--group',
            areas: holdsNow
              ? [
                  {
                    // Centred on the clock, kept inside its group (an hour group is narrow at 15 minutes).
                    left: `clamp(0px, calc(${percent((NOW_TICKS - start) / span)} - 21px), calc(100% - 42px))`,
                    width: 42,
                    top: 3,
                    bottom: 3,
                    cssClass: 'bd-now-tag',
                    html: nowTag,
                    toolTip: nowTip,
                  },
                ]
              : [],
          }
        } else {
          const classes = ['bd-th', 'bd-th--cell']
          if (holdsNow) classes.push('bd-th--now')
          else if (start + span <= NOW_TICKS) classes.push('bd-th--past')
          painted = { html: cellHeaderHtml(start, span), cssClass: classes.join(' '), areas: [] }
        }
        cache.set(key, painted)
      }
      header.html = painted.html
      header.cssClass = painted.cssClass
      header.areas = painted.areas
    }
    return { level, hook }
  }, [level, tag, text]).hook

  // Hover card: the `show job` box, from the rules.
  const card = useCallback(
    (event: SuperScheduler.Event): ReactNode | null => {
      const job = byId.get(String(event.id()))
      return job === undefined ? null : (
        <JobCard job={job} jobs={jobs} issues={issues} text={text} tag={tag} />
      )
    },
    [byId, jobs, issues, text, tag],
  )
  const { eventHover, closeHover, suppressHover } = useJobHover(card)

  // Drag card: the command the drop runs, where it lands, then the refusal or the warnings.
  const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
    const cache = new Map<string, string>()
    return {
      duration: false,
      row: false,
      markerFormat: 'HH:mm',
      html: (info) => {
        const id = info.eventId === null ? null : String(info.eventId)
        const job = id === null ? undefined : byId.get(id)
        const place = info.resource
        if (job === undefined || (!isNode(place) && !isQueue(place))) return null
        const selection = selectedIds(panesRef.current, id ?? '')
        const key = `${job.id}|${String(place)}|${info.start.getTime()}|${selection.join(',')}`
        let html = cache.get(key)
        if (html === undefined) {
          const decision = judge(job, place, info.start.getTime(), selection)
          const target = decision.targets.find((candidate) => candidate.id === job.id)
          const start = target?.start ?? info.start.getTime()
          const refused = decision.plan.refused
          const message =
            refused === null ? null : refusalMessage(refused.job, refused.refusal, job, text, tag)
          const warnings =
            message === null
              ? isQueue(place) && isNode(job.place)
                ? [text.verdict.requeue]
                : verdictWarnings(job, decision.plan, text, tag)
              : []
          html = verdictHtml(
            decision.command,
            String(place),
            start,
            start + walltime(job),
            message,
            warnings,
            tag,
          )
          if (cache.size > 300) cache.clear()
          cache.set(key, html)
        }
        return html
      },
    }
  }, [byId, judge, panesRef, text, tag])

  // Context menu: rebuilt with the board, so its labels name the real consequence.
  const contextMenu = useMemo(
    () =>
      createEventMenu({
        theme: skinMenuTheme(SLUG),
        title: (event) => event.text(),
        onOpenChange: (open) => suppressHover(open),
        entries: (event) => {
          const id = String(event.id())
          const job = byId.get(id)
          if (job === undefined) return []
          const state = stateOf(job)
          const entries: MenuEntry[] = [
            {
              label: text.actions.show,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('show', id),
            },
          ]
          if (state === 'queued' || state === 'preempted' || state === 'scheduled') {
            const fit = bestFit(job, jobs)
            entries.push({
              label:
                fit === null
                  ? text.actions.noFit
                  : fit.preempts.length > 0
                    ? plural(text.actions.runPreempts, fit.preempts.length, tag, {
                        node: fit.place,
                        time: clock(fit.start, tag),
                      })
                    : format(text.actions.run, { node: fit.place, time: clock(fit.start, tag) }),
              icon: 'slot',
              primary: state !== 'scheduled',
              disabled:
                fit === null || (fit.place === job.place && fit.start === parseCivil(job.start)),
              run: () => onAction('run', id),
            })
          }
          if (state === 'scheduled') {
            const chain = cascade(id, jobs)
            if (chain !== null)
              entries.push({
                label: plural(text.actions.cascade, chain.length, tag, {
                  upstream: job.after ?? '',
                }),
                icon: 'after',
                primary: true,
                run: () => onAction('cascade', id),
              })
            entries.push({
              label: text.actions.requeue,
              icon: 'pending',
              run: () => onAction('requeue', id),
            })
          }
          if (state === 'queued' || state === 'preempted')
            entries.push({
              label: text.actions.hold,
              icon: 'pending',
              run: () => onAction('hold', id),
            })
          if (state === 'held')
            entries.push({
              label: text.actions.release,
              icon: 'confirm',
              primary: true,
              run: () => onAction('release', id),
            })
          if (
            state === 'queued' ||
            state === 'preempted' ||
            state === 'held' ||
            state === 'scheduled'
          ) {
            entries.push('separator')
            for (const priority of PRIORITIES)
              if (priority !== job.priority)
                entries.push({
                  label: format(text.actions.prio, { priority: text.priorities[priority] }),
                  icon: 'layers',
                  run: () => onAction(`prio-${priority}`, id),
                })
          }
          return entries
        },
      }),
    [byId, jobs, text, tag, suppressHover, onAction],
  )

  // Pipelines from the data: finish-to-start links between booked jobs; late ones are red.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    const late = new Set(issues.filter((issue) => issue.kind === 'late').map((issue) => issue.job))
    return events.flatMap((event): SuperScheduler.LinkData[] => {
      const after = event.after
      const upstream = after === undefined ? undefined : byId.get(after)
      if (after === undefined || upstream === undefined) return []
      if (!isNode(event.resource) || !isNode(upstream.place)) return []
      const id = String(event.id)
      return [
        {
          id: `${after}->${id}`,
          from: after,
          to: event.id,
          type: 'FinishToStart',
          cssClass: late.has(id) ? 'bd-link bd-link--late ss-link--late' : 'bd-link',
        },
      ]
    })
  }, [events, issues, byId])

  // Columns belong to the nodes pane; the queue's rows span the whole row header.
  const columns = useMemo<SuperScheduler.RowHeaderColumnData[]>(
    () =>
      narrow
        ? [{ text: text.columns.node, width: BOARD.narrowColumn, nonresizable: true }]
        : [
            { text: text.columns.node, width: BOARD.columns.node, nonresizable: true },
            { text: text.columns.gpu, width: BOARD.columns.gpu, nonresizable: true },
            ...(compact
              ? []
              : [{ text: text.columns.next, width: BOARD.columns.next, nonresizable: true }]),
          ],
    [narrow, compact, text],
  )
  const headerWidth = narrow
    ? BOARD.narrowColumn
    : compact
      ? BOARD.columns.node + BOARD.columns.gpu
      : BOARD.rowHeaderWidth
  // The nodes pane fits its rows as they open (the queue takes the rest), sized once per mount.
  const [nodesSize] = useState(() => nodesPaneHeight(jobs))
  const panes = useMemo<SchedulerPane[]>(
    () => [
      {
        id: 'nodes',
        rowFilter: (resource) => rowKind(resource) !== 'queue',
        size: nodesSize,
        minSize: BOARD.nodesMin,
        props: { rowHeaderColumns: columns },
      },
      {
        id: 'queue',
        rowFilter: (resource) => rowKind(resource) === 'queue',
        minSize: BOARD.queueMin,
        props: { zoomLevels: QUEUE_LEVELS },
      },
    ],
    [columns, nodesSize],
  )

  // Once both panes rendered: the first scroll (imperative and once: a scroll prop would be
  // re-applied on every update), then the demo picks up the controls (minimap, views).
  const rendered = useRef(new Set<string>())
  const narrowRef = useRef(narrow)
  const ready = useRef(onPanes)
  useEffect(() => {
    ready.current = onPanes
  }, [onPanes])
  const onAfterRender = useCallback(
    (args: { readonly pane?: string }) => {
      const pane = args.pane
      if (pane === undefined || rendered.current.has(pane)) return
      rendered.current.add(pane)
      if (rendered.current.size < 2) return
      requestAnimationFrame(() => {
        // Phones open three hours before the clock; wider stages show the morning's history too.
        panesRef.current?.scrollTo(
          narrowRef.current ? civil(NOW_TICKS - 3 * HOUR) : INITIAL_VIEW_START,
          'left',
        )
        ready.current()
      })
    },
    [panesRef],
  )

  const shared = useMemo<
    Omit<SchedulerProps, 'height' | 'heightSpec' | 'resources' | 'controlRef'>
  >(() => {
    const issueOf = new Map(issues.map((issue) => [issue.job, issue]))
    const usage = new Map(
      RACKS.flatMap((rack) => rack.nodes).map((node) => [node, usageAt(node, jobs, NOW_TICKS)]),
    )
    const racks = rackUsage(jobs)
    const loads = new Map(
      RACKS.flatMap((rack) => rack.nodes).map((node) => [
        node,
        nodeLoad(node, jobs, NOW_TICKS - (NOW_TICKS % HOUR), 12),
      ]),
    )
    const queues = new Map(
      PRIORITIES.map((priority) => {
        const waiting = jobs.filter((job) => job.place === `q-${priority}`)
        return [
          priority,
          { count: waiting.length, gpus: waiting.reduce((sum, job) => sum + job.gpus, 0) },
        ]
      }),
    )
    const cellCache = new Map<
      string,
      { html: string; css: string; disabled: boolean; areas: SuperScheduler.AreaData[] }
    >()
    const cardCache = new Map<string, string>()
    const nodeIds = RACKS.flatMap((rack) => rack.nodes)
    const isRack = (id: SuperScheduler.ResourceId) => RACKS.some((rack) => rack.id === id)
    const asNode = (id: SuperScheduler.ResourceId): NodeId | null => {
      const found = nodeIds.find((node) => node === id)
      return found ?? null
    }

    return {
      locale: schedulerLocale,
      cssClass: skinClass(SLUG),
      startDate: PLAN.start,
      days: PLAN.days,
      weekStarts: 1,
      zoomLevels: NODE_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      zoomGesture: { min: 12, max: 160 },
      onBeforeTimeHeaderRender: timeHeaders,
      // Exact times: a job booked at 14:15 starts at 14:15. Drops land on the quarter hour under
      // the pointer at every zoom level (onEventMoving steers them), not on whole cells.
      useEventBoxes: 'Never',
      snapToGridEventMoving: false,
      cellsMarkBusiness: false,
      treeEnabled: true,
      treePreventParentUsage: true,
      treeIndent: BOARD.treeIndent,
      rowHeaderWidth: headerWidth,
      rowHeaderWidthAutoFit: false,
      rowHeaderColumnsMergeParents: true,
      eventHeight: BOARD.eventHeight,
      eventMarginBottom: BOARD.eventGap,
      rowMarginTop: BOARD.rowMarginTop,
      rowMarginBottom: BOARD.rowMarginBottom,
      // A node runs several jobs at once while their GPUs fit: overlap is the application's rule.
      allowEventOverlap: true,
      eventResizeHandling: 'Disabled',
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      allowMultiMove: true,
      multiMoveVerticalMode: 'Disabled',
      rectangleSelectHandling: 'EventSelect',
      rectangleSelectModifiers: true,
      durationBarVisible: false,
      linkShape: 'RightAngled',
      linkLayer: 'Below',
      crosshairType: 'Full',
      // The cluster clock: a fixed instant of the scenario, not the visitor's clock.
      separators: [{ location: NOW, layer: 'AboveEvents', width: 1, cssClass: 'bd-now' }],
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      links,
      eventHover,
      dragCard,
      contextMenu,
      onEventsChange,

      onBeforeCornerRender: (args) => {
        const title = format(text.chrome.nodesPane, {
          nodes: String(nodeIds.length),
          gpus: String(nodeIds.length * 8),
        })
        const [name = '', ...rest] = title.split(' · ')
        args.html = `<span class="bd-corner" aria-hidden="true"><b>${escape(name)}</b><span class="bd-corner__meta">· ${escape(rest.join(' · '))}</span></span>`
      },

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const id = row.id
        const [first, second, third] = row.columns
        if (id === SUMMARY_ID) {
          row.cssClass = 'bd-rh bd-rh--sum'
          const html = summaryHeaderHtml(text)
          if (first === undefined) row.html = html
          else {
            first.html = html
            if (second !== undefined) second.html = summaryMetaHtml(text.rows.summaryMeta[level])
            if (third !== undefined) third.html = summaryLegendHtml()
          }
          return
        }
        if (isQueue(id)) {
          const priority = priorityOfQueue(id)
          const queue = queues.get(priority) ?? { count: 0, gpus: 0 }
          row.cssClass = `bd-rh bd-rh--queue bd-rh--${priority}`
          row.html = queueHtml(priority, queue.count, queue.gpus, text, tag)
          if (first !== undefined) first.html = row.html
          return
        }
        const rack = RACKS.find((candidate) => candidate.id === id)
        if (rack !== undefined) {
          row.cssClass = 'bd-rh bd-rh--rack'
          // Parent rows span the columns (rowHeaderColumnsMergeParents): their header is row.html.
          row.html = rackHtml(rack, racks.get(rack.id) ?? 0, text)
          if (first !== undefined) first.html = row.html
          return
        }
        const node = asNode(id)
        if (node === null) return
        const used = usage.get(node) ?? 0
        row.cssClass = `bd-rh bd-rh--node bd-rh--${classOf(node)}`
        const html = nodeHtml(node, used, text)
        if (first === undefined) {
          row.html = html
          return
        }
        first.html = html
        if (second !== undefined) second.html = meterHtml(node, used)
        if (third !== undefined) third.html = sparkHtml(node, loads.get(node) ?? [])
      },

      // `:filter class=A`: racks and nodes of another class step out (the cluster row stays).
      onRowFilter: (args) => {
        const filter = args.filterParam
        if (typeof filter !== 'string' || args.row.id === SUMMARY_ID) return
        const rack = RACKS.find((candidate) => candidate.id === args.row.id)
        const node = asNode(args.row.id)
        args.visible =
          rack !== undefined ? rack.gpuClass === filter : node === null || classOf(node) === filter
      },

      // The live search and `:filter team=… state=…`, one predicate for both panes.
      onEventFilter: (args) => {
        const filter = args.filterParam
        if (filter === null || typeof filter !== 'object') return
        const job = byId.get(String(args.e.id()))
        if (job === undefined) return
        const query = 'query' in filter && typeof filter.query === 'string' ? filter.query : ''
        const terms: FilterTerms =
          'terms' in filter && typeof filter.terms === 'object' && filter.terms !== null
            ? filter.terms
            : {}
        args.visible = jobMatches(job, query) && passesFilter(job, terms)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        const from = cell.start.getTime()
        const to = cell.end.getTime()
        const width = args.control.cellWidth ?? BOARD.cellWidth['1h']
        const key = `${String(cell.resource)}|${from}|${to}|${width < 34 ? 'n' : 'w'}`
        let painted = cellCache.get(key)
        if (painted === undefined) {
          const past = to <= NOW_TICKS ? ' bd-c--past' : from < NOW_TICKS ? ' bd-c--now' : ''
          const plain = (css: string, html = '') => ({ html, css, disabled: false, areas: [] })
          if (cell.resource === SUMMARY_ID) {
            const value = allocation(jobs, from, to - from, 1)[0] ?? 0
            painted = plain(`bd-c bd-c--sum${past}`, summaryCellHtml(value, width, text, tag))
          } else if (isRack(cell.resource)) {
            painted = plain(`bd-c bd-c--rack${past}`)
          } else if (isQueue(cell.resource)) {
            painted = plain(`bd-c bd-c--queue${past}`)
          } else {
            const node = asNode(cell.resource)
            const drain = node === null ? undefined : drainsBetween(node, from, to)[0]
            const begins = drain !== undefined && parseCivil(drain.start) >= from
            painted =
              drain === undefined
                ? plain(`bd-c${past}`)
                : {
                    // A drained node refuses drops (disabled cells); a label where the drain starts.
                    html: '',
                    css: `bd-c bd-c--drain${begins ? ' bd-c--drain-start' : ''}${past}`,
                    disabled: true,
                    areas: begins
                      ? [
                          {
                            left: percent((parseCivil(drain.start) - from) / (to - from)),
                            top: 0,
                            height: 16,
                            width: 160,
                            cssClass: 'bd-drain',
                            html: drainHtml(drain.reason, text),
                          },
                        ]
                      : [],
                  }
          }
          if (cellCache.size > 4000) cellCache.clear()
          cellCache.set(key, painted)
        }
        cell.properties.html = painted.html
        cell.properties.cssClass = painted.css
        cell.properties.areas = painted.areas
        if (painted.disabled) cell.properties.disabled = true
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const id = String(data.id)
        const job = byId.get(id)
        if (job === undefined || readJobFields(data) === null) return
        const state = stateOf(job)
        const issue = issueOf.get(id)
        const flags = { late: issue?.kind === 'late', waiting: issue?.kind === 'waiting' }
        const key = `${id}|${job.place}|${job.start}|${state}|${flags.late ? 1 : 0}${flags.waiting ? 1 : 0}`
        let html = cardCache.get(key)
        if (html === undefined) {
          html = jobHtml(job, state, flags, text, tag)
          cardCache.set(key, html)
        }
        data.html = html
        const classes = ['bd-ev', `bd-ev--${state}`, `bd-ev--${job.priority}`]
        if (flags.late) classes.push('bd-ev--late')
        data.cssClass = classes.join(' ')
        data.ariaLabel = jobLabel(job, state, flags, text)
      },

      onEventMoving: (args) => {
        const id = String(args.e.id())
        const job = byId.get(id)
        const place = args.resource
        if (job === undefined || (!isNode(place) && !isQueue(place))) {
          args.allowed = false
          return
        }
        const memberIds =
          args.multimove.length > 1 ? args.multimove.map((item) => String(item.event.id())) : [id]
        const decision = judge(job, place, args.start.getTime(), memberIds)
        const master = decision.targets.find((target) => target.id === id)
        if (master !== undefined) {
          // Steer the shadow to where the job will really land (quarters, never in the past).
          args.start = new SuperScheduler.Date(civil(master.start))
          args.end = new SuperScheduler.Date(civil(master.start + walltime(job)))
        }
        for (const item of args.multimove) {
          const target = decision.targets.find(
            (candidate) => candidate.id === String(item.event.id()),
          )
          const original = byId.get(String(item.event.id()))
          if (target === undefined || original === undefined) continue
          item.start = new SuperScheduler.Date(civil(target.start))
          item.end = new SuperScheduler.Date(civil(target.start + walltime(original)))
        }
        const refused = decision.plan.refused
        if (refused !== null) {
          const message = refusalMessage(refused.job, refused.refusal, job, text, tag)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        refusal.track(id, null)
      },

      // The drop: the library's own commit is cancelled; the demo applies the move (and what it
      // displaces) as one state change and one undo step. Keyboard moves arrive here too.
      onEventMove: (args) => {
        args.preventDefault()
        refusal.clear()
        const id = String(args.e.id())
        const job = byId.get(id)
        const place = args.newResource
        if (job === undefined || (!isNode(place) && !isQueue(place))) return
        const memberIds =
          args.multimove.length > 1 ? args.multimove.map((item) => String(item.event.id())) : [id]
        const decision = judge(job, place, args.newStart.getTime(), memberIds)
        const refused = decision.plan.refused
        if (refused !== null) {
          onRefused(id, refusalMessage(refused.job, refused.refusal, job, text, tag))
          return
        }
        onMove({ targets: decision.targets, shift: decision.shift })
      },

      onAfterRender,
      onEventClick: () => closeHover(),
      onEventSelected: () => onSelection(),
      onRectangleSelected: () => onSelection(),

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const next = ZOOM_IDS[args.level]
        if (next === undefined) return
        setLevel(next)
        onZoomLevel(next)
      },
    }
  }, [
    schedulerLocale,
    initialZoom,
    timeHeaders,
    headerWidth,
    history,
    links,
    eventHover,
    dragCard,
    contextMenu,
    onEventsChange,
    issues,
    jobs,
    byId,
    text,
    tag,
    level,
    judge,
    refusal,
    onRefused,
    onMove,
    closeHover,
    onSelection,
    onZoomLevel,
    onAfterRender,
  ])

  // The rest of the board follows the panes once both exist: first scroll, filters, labels.
  const control = useCallback(
    (pane: 'nodes' | 'queue') => {
      const found = panesRef.current?.control(pane) ?? null
      return found === null || found.disposed() ? null : found
    },
    [panesRef],
  )

  useEffect(() => {
    panesRef.current?.forEach((pane) => {
      if (!pane.disposed()) pane.events.filter(eventFilter)
    })
  }, [eventFilter, panesRef, height])

  useEffect(() => {
    control('nodes')?.rows.filter(classFilter)
  }, [classFilter, control, height])

  // The visible range, published once scrolling (or a minimap drag) settles.
  useEffect(() => {
    const nodes = control('nodes')
    if (nodes === null) return
    return subscribeScheduler(nodes, 'viewport', (viewport) =>
      onViewport(viewport.start.ticks, viewport.end.ticks),
    )
  }, [control, onViewport, height])

  return height > 0 ? (
    <SchedulerPanes
      {...shared}
      panesRef={panesRef}
      panes={panes}
      height={height}
      resources={resources}
      events={events}
      splitter={{ size: BOARD.splitter, step: 16 }}
      timeHeader="first"
      component={SuperSchedulerComponent}
    />
  ) : null
}

/**
 * The decision for one frame of a drag: where each member lands and what the rules say. A
 * multi-move shifts every booked member by the master's snapped shift; queued members stay.
 * Cached per frame: holding the pointer still costs a map lookup.
 */
function createJudge(jobs: readonly Job[], byId: ReadonlyMap<string, Job>) {
  const cache = new Map<string, Judgement>()
  return (master: Job, place: NodeId | QueueId, rawStart: number, memberIds: readonly string[]) => {
    const key = `${master.id}|${place}|${rawStart}|${memberIds.join(',')}`
    const cached = cache.get(key)
    if (cached !== undefined) return cached
    let judgement: Judgement
    if (memberIds.length <= 1) {
      const start = landing(master, place, rawStart, jobs)
      const plan = planMove(jobs, [{ id: master.id, place, start }])
      judgement = {
        targets: [{ id: master.id, place, start }],
        shift: null,
        plan,
        command: commandOf(master, place, start, plan.preempted.length > 0),
      }
    } else {
      const shift = isNode(master.place)
        ? landing(master, master.place, rawStart, jobs) - parseCivil(master.start)
        : 0
      const targets = memberIds.flatMap((id): MoveTarget[] => {
        const member = byId.get(id)
        return member === undefined || !isNode(member.place)
          ? []
          : [{ id, place: member.place, start: parseCivil(member.start) + shift }]
      })
      judgement = {
        targets,
        shift,
        plan: planMove(jobs, targets),
        command: `'<,'>mv ${shiftText(shift)}`,
      }
    }
    if (cache.size > 300) cache.clear()
    cache.set(key, judgement)
    return judgement
  }
}

interface Judgement {
  readonly targets: readonly MoveTarget[]
  readonly shift: number | null
  readonly plan: MovePlan
  readonly command: string
}

/** Ids of the selected jobs when `id` is one of them (a multi-move), else just `id`. */
function selectedIds(panes: SchedulerPanesHandle | null, id: string): string[] {
  if (panes === null) return [id]
  const ids: string[] = []
  panes.forEach((pane) => {
    if (pane.disposed()) return
    for (const event of pane.multiselect.events()) ids.push(String(event.id()))
  })
  return ids.includes(id) && ids.length > 1 ? ids : [id]
}

L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.