frontend: Rugnux live stage - steps tagged in a few letters, one colour per kind

The step segments on rugnux's bar and the chips under it carry a tag of a few letters for the kind of step
(Pre, Spots, Idx, Int, Scale, MR, Ref, Out, ...), from a small table on the step's title - a title the table
does not know gives its first word - and the full title on hover; no counts, no pass numbers. Every kind
has a colour of its own (Okabe-Ito, the blue lightened for the dark stage; a kind outside the table takes
the next spare one), the same on every pass and on its chip; consecutive passes of one kind are one chip
with their time together and, on the bar, segments parted by a thin divider with the tag on the first.
The centre panel shows only the step rugnux is on while there is no model; the steps behind it are on the
strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK
This commit is contained in:
leonarski_fandClaude Opus 5.5 committed 2026-11-11 17:43:28 +01:00
1 parent 9230543395
commit bae1cc3b7b
2 files changed
+82 -47

No files matched your search

+11 -25
View File
@@ -3,7 +3,7 @@ import {green, indigo, lime, orange, red, grey} from "@mui/material/colors";
import {keyframes} from "@mui/system";
import {useQuery} from "@tanstack/react-query";
import {getConfigRugnuxOptions, getRugnuxStatusOptions} from "../client/@tanstack/react-query.gen";
import {rugnux_live_status, rugnux_live_step} from "../client";
import {rugnux_live_status} from "../client";
import {useTokenRequired} from "../auth";
import EmptyState from "./EmptyState";
import RugnuxTriage from "./RugnuxTriage";
@@ -16,7 +16,6 @@ import RugnuxProgress from "./RugnuxProgress";
import {lastImageTime, ModelScores, StepTable, VerdictAlert} from "./RugnuxParts";
import BoltIcon from "@mui/icons-material/Bolt";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import CheckIcon from "@mui/icons-material/Check";
import {lazy, Suspense, useEffect, useRef, useState} from "react";
// The live page as one control-room screen: a dark stage the height of the window, edged in the colour of the
@@ -110,34 +109,21 @@ function Later({text}: { text: string }) {
return <Typography sx={{color: "text.disabled", m: "auto", textAlign: "center", px: 2}}>{text}</Typography>;
}
// The run's steps as a console - the running one large with its progress, the ones behind it as a list with
// their durations - filling the centre until there is a model to show. Their notes are in the Details fold.
// The step rugnux is on, large with its progress, filling the centre until there is a model to show; the
// steps behind it are on the strip under the bars.
function RunConsole({s}: { s: rugnux_live_status }) {
const running = s.state === "Running";
const current = running && s.steps.length > 0 && s.steps[s.steps.length - 1].duration_s === undefined
? s.steps[s.steps.length - 1] : undefined;
const done = current ? s.steps.slice(0, -1) : s.steps;
const row = (step: rugnux_live_step, i: number) => (
<Box key={i} sx={{display: "grid", gridTemplateColumns: "24px 1fr auto", alignItems: "center", gap: 1.5, py: 0.6,
borderBottom: "1px solid", borderColor: "divider"}}>
<CheckIcon sx={{fontSize: 18, color: step.duration_s === undefined ? "text.disabled" : green[400]}}/>
<Typography noWrap sx={{fontWeight: 600, minWidth: 0}}>{step.title}</Typography>
<Typography sx={{fontVariantNumeric: "tabular-nums", color: "text.secondary", whiteSpace: "nowrap"}}>
{step.duration_s !== undefined ? `${step.duration_s.toFixed(1)} s` : ""}
</Typography>
</Box>
);
if (!current)
return <Later text={running ? "waiting for images" : s.state === "Failed" ? "failed" : "no model"}/>;
return (
<Box sx={{overflowY: "auto", minHeight: 0, display: "flex", flexDirection: "column", gap: 2}}>
{current && <Box sx={{py: 2}}>
<Typography sx={{...LABEL_SX, color: indigo[300]}}>now</Typography>
<Typography sx={{fontSize: "2rem", fontWeight: 800, lineHeight: 1.1, animation: `${pulse} 2.4s ease-in-out infinite`,
"@media (prefers-reduced-motion: reduce)": {animation: "none"}}}>
{current.title}{s.step_progress !== undefined && <Box component="span" sx={{color: indigo[300], ml: 2}}>{(100 * s.step_progress).toFixed(0)} %</Box>}
</Typography>
</Box>}
{s.steps.length === 0 && <Later text={running ? "Rugnux starts on the first images" : "No steps ran"}/>}
<Box>{done.map(row)}</Box>
<Box sx={{py: 2}}>
<Typography sx={{...LABEL_SX, color: indigo[300]}}>now</Typography>
<Typography sx={{fontSize: "2rem", fontWeight: 800, lineHeight: 1.1, animation: `${pulse} 2.4s ease-in-out infinite`,
"@media (prefers-reduced-motion: reduce)": {animation: "none"}}}>
{current.title}{s.step_progress !== undefined && <Box component="span" sx={{color: indigo[300], ml: 2}}>{(100 * s.step_progress).toFixed(0)} %</Box>}
</Typography>
</Box>
);
}
+71 -22
View File
@@ -1,15 +1,16 @@
import {useEffect, useRef, useState} from "react";
import {Box, Tooltip, Typography} from "@mui/material";
import {keyframes} from "@mui/system";
import {deepOrange, grey, indigo, lightBlue, lime} from "@mui/material/colors";
import {rugnux_live_status} from "../client";
import {deepOrange, grey, indigo, lime} from "@mui/material/colors";
import {rugnux_live_status, rugnux_live_step} from "../client";
import {lastImageTime} from "./RugnuxParts";
// The detector's and rugnux's progress one under the other on one time axis (seconds since the first image):
// the detector's bar fills as images arrive, up to the collection's expected end; rugnux's bar is its steps,
// one segment each, as wide as the step took - the running one growing to "now" - so what takes how long is
// read off the bar, and the strip under the bars names every step with its duration. The last image and the
// deadline are lines across both bars.
// read off the bar. Each kind of step has a tag of a few letters and a colour of its own, the same on every
// pass and on the strip under the bars that gives each kind its time; the full titles are on hover. The last
// image and the deadline are lines across both bars.
const stripes = keyframes`
from { background-position: 0 0; }
@@ -28,7 +29,38 @@ const runningSx = {
"@media (prefers-reduced-motion: reduce)": {animation: "none"},
};
const STEP_COLOURS = [indigo[400], lightBlue[700], indigo[600], lightBlue[900], indigo[300], lightBlue[500]];
// The step's tag from its title; a title no row knows is its first word.
const TAGS: [RegExp, string][] = [
[/^pre-?(scan|pass)/i, "Pre"], [/spot/i, "Spots"], [/index/i, "Idx"], [/integrat/i, "Int"], [/gpu analysis/i, "GPU"],
[/scaling/i, "Scale"], [/^merge/i, "Merge"], [/molecular replacement/i, "MR"], [/geometry refinement/i, "Geom"],
[/refinement/i, "Ref"], [/validation/i, "Val"], [/analys/i, "Chk"], [/writ/i, "Out"], [/distance/i, "Dist"],
[/doubled-cell/i, "Cell"], [/calibration/i, "Cal"], [/reading/i, "Read"],
];
const tagOf = (title: string) => TAGS.find(([re]) => re.test(title))?.[1] ?? title.split(/[\s,]/)[0].slice(0, 6);
// One colour per kind of step - Okabe-Ito, which keeps its kinds apart for colour-blind readers too, with
// its blue lightened for the dark stage; a kind not in the table takes the next free one.
const KIND_COLOURS: Record<string, string> = {
Pre: "#56B4E9", Spots: "#E69F00", Idx: "#F0E442", Int: "#3C8DD6", GPU: "#3C8DD6", Scale: "#009E73",
Merge: "#009E73", MR: "#CC79A7", Ref: "#D55E00", Out: "#9E9E9E",
};
const SPARE_COLOURS = ["#8DD3C7", "#BEBADA", "#FB8072", "#FDB462", "#B3DE69"];
function kindColours(steps: rugnux_live_step[]) {
const colours: Record<string, string> = {...KIND_COLOURS};
let spare = 0;
for (const st of steps) {
const tag = tagOf(st.title);
if (!(tag in colours))
colours[tag] = SPARE_COLOURS[spare++ % SPARE_COLOURS.length];
}
return colours;
}
// dark text on the light colours
const inkOn = (hex: string) => {
const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "rgba(0,0,0,.85)" : "#fff";
};
const BAR = 20;
const GAP = 6;
const AXIS = 16;
@@ -85,6 +117,21 @@ function RugnuxProgress({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu
: s.cut ? `cut (${s.cut})` : `${s.steps.length} steps · ${seconds(s.elapsed_s)}`;
const stepEnd = (i: number) => s.steps[i].duration_s === undefined ? s.elapsed_s : s.steps[i].started_s + s.steps[i].duration_s!;
const colours = kindColours(s.steps);
const tags = s.steps.map(st => tagOf(st.title));
// consecutive steps of one kind (the passes) as one chip, with their time together
const kinds: { tag: string, titles: string[], seconds: number, open: boolean }[] = [];
s.steps.forEach((st, i) => {
const last = kinds[kinds.length - 1];
const open = st.duration_s === undefined;
const duration = open ? s.elapsed_s - st.started_s : st.duration_s!;
if (last && last.tag === tags[i]) {
last.titles.push(st.title);
last.seconds += duration;
last.open = open;
} else
kinds.push({tag: tags[i], titles: [st.title], seconds: duration, open});
});
return (
<Box sx={{display: "grid", gridTemplateColumns: "72px minmax(0, 1fr) auto", columnGap: 2, alignItems: "start"}}>
@@ -107,15 +154,18 @@ function RugnuxProgress({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu
{s.steps.map((st, i) => {
const open = st.duration_s === undefined;
const width = Math.max(pct(stepEnd(i)) - pct(st.started_s), 0.3);
const showTitle = width / 100 * laneWidth >= Math.min(st.title.length * 6.5 + 10, 60);
const colour = colours[tags[i]];
// a pass after a pass of the same kind: a thin divider, the tag only on the first
const samePass = i > 0 && tags[i - 1] === tags[i];
const showTag = !samePass && width / 100 * laneWidth >= tags[i].length * 8 + 14;
return (
<Tooltip key={i} arrow title={`${st.title} · ${open ? "running" : seconds(st.duration_s!)}`}>
<Box sx={{position: "absolute", left: `${pct(st.started_s)}%`, width: `${width}%`, top: 0, bottom: 0,
bgcolor: STEP_COLOURS[i % STEP_COLOURS.length], borderRight: "1px solid rgba(0,0,0,.5)",
bgcolor: colour, borderLeft: samePass ? "1px solid rgba(0,0,0,.35)" : "1px solid rgba(0,0,0,.7)",
display: "flex", alignItems: "center", px: 0.5, overflow: "hidden",
...(open && running && runningSx)}}>
{showTitle && <Typography noWrap sx={{fontSize: "0.72rem", fontWeight: 600, color: "#fff", position: "relative", zIndex: 1}}>
{st.title}
{showTag && <Typography noWrap sx={{fontSize: "0.72rem", fontWeight: 700, color: inkOn(colour), position: "relative", zIndex: 1}}>
{tags[i]}
</Typography>}
</Box>
</Tooltip>
@@ -154,25 +204,24 @@ function RugnuxProgress({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu
</Typography>
</Box>
{/* the steps, each with its duration */}
{s.steps.length > 0 &&
{/* the kinds of step, each with its time */}
{kinds.length > 0 &&
<Box sx={{gridColumn: "2 / 4", display: "flex", flexWrap: "wrap", gap: 0.75, mt: 0.25}}>
{s.steps.map((st, i) => {
const open = st.duration_s === undefined;
return (
<Box key={i} sx={{display: "inline-flex", alignItems: "center", gap: 0.75, px: 1, height: 22, borderRadius: "11px",
{kinds.map((k, i) => (
<Tooltip key={i} arrow title={k.titles.join(" · ")}>
<Box sx={{display: "inline-flex", alignItems: "center", gap: 0.75, px: 1, height: 22, borderRadius: "11px",
bgcolor: "rgba(255,255,255,.06)", fontSize: "0.75rem", whiteSpace: "nowrap",
...(open && running && {animation: `${pulse} 1.6s ease-in-out infinite`,
...(k.open && running && {animation: `${pulse} 1.6s ease-in-out infinite`,
"@media (prefers-reduced-motion: reduce)": {animation: "none"}})}}>
<Box sx={{width: 8, height: 8, borderRadius: "2px", bgcolor: STEP_COLOURS[i % STEP_COLOURS.length]}}/>
<Box component="span" sx={{fontWeight: 600}}>{st.title}</Box>
<Box sx={{width: 8, height: 8, borderRadius: "2px", bgcolor: colours[k.tag]}}/>
<Box component="span" sx={{fontWeight: 700}}>{k.tag}</Box>
<Box component="span" sx={{color: "text.secondary", fontVariantNumeric: "tabular-nums"}}>
{!open ? seconds(st.duration_s!)
: running && i === s.steps.length - 1 && s.step_progress !== undefined ? `${(100 * s.step_progress).toFixed(0)} %` : ""}
{k.open && running && i === kinds.length - 1 && s.step_progress !== undefined
? `${(100 * s.step_progress).toFixed(0)} %` : seconds(k.seconds)}
</Box>
</Box>
);
})}
</Tooltip>
))}
</Box>}
</Box>
);