From 39fff874e82d19972a45afcbd87ec1282bb7a391 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 18 Oct 2026 12:25:18 +0200 Subject: [PATCH 1/2] frontend: rugnux live gets a full-width dashboard row, a timeline that fits the run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: the Rugnux card leaves the narrow right column for a full-width row between the first plot and the second, with the timeline, the verdict and key results (space group, cell, resolution, completeness, signal) beside the step table; it collapses to one column on a narrow screen and renders nothing (no empty grid cell) when rugnux has not run. The measurement statistics row that showed "-" is the cell given with the collection, now labelled "Unit cell (given)". Timeline: the deadline sets the axis only while the analysis runs, so a finished run fills the axis; marker labels that would overlap ("now" on "last image") move up a row; the image count goes beside the detector bar when it does not fit in it; a step title too narrow to read is left to the tooltip instead of being cut to one letter. Rugnux tab: the failure message is a wrapped alert under the header, so the latency number stays right-aligned; summary tiles are highlighted by label only, and only space group and cell share a row, so no half-empty rows. Status bar: with rugnux live on, the collection message names the analysis state alongside ("Data collection without problems · rugnux analysis: failed", raised to a warning when it failed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4 --- frontend/src/App.tsx | 6 +- frontend/src/components/ErrorMessage.tsx | 21 ++++ .../src/components/MeasurementStatistics.tsx | 2 +- frontend/src/components/RugnuxPage.tsx | 73 +++++------ frontend/src/components/RugnuxParts.tsx | 29 ++++- frontend/src/components/RugnuxStatus.tsx | 101 ++++++++++------ frontend/src/components/RugnuxTimeline.tsx | 113 +++++++++++++----- 7 files changed, 237 insertions(+), 108 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23c657b5d..d24cf6086 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -171,11 +171,9 @@ function App() { - - - - + + diff --git a/frontend/src/components/ErrorMessage.tsx b/frontend/src/components/ErrorMessage.tsx index 64fb2d927..de16bb415 100644 --- a/frontend/src/components/ErrorMessage.tsx +++ b/frontend/src/components/ErrorMessage.tsx @@ -2,6 +2,8 @@ import {memo, ReactNode} from 'react'; import {broker_status} from "../client"; import {Alert, AlertColor} from "@mui/material"; +import {useQuery} from "@tanstack/react-query"; +import {getConfigRugnuxOptions, getRugnuxStatusOptions} from "../client/@tanstack/react-query.gen"; type MyProps = { s?: broker_status @@ -27,13 +29,32 @@ function alert(input: AlertColor, message: string) : ReactNode { return {message} } +// The broker messages that report on a collection (rather than on a calibration or a configuration). +const COLLECTION_MESSAGE = /measuring|data collection|stream receiver/i; + function ErrorMessage({s}: MyProps) { + // The broker's message is about the collection; with rugnux live on, its analysis of the same + // collection runs on after it and can fail where the collection did not, so it is named alongside. + const {data: rugnuxSettings} = useQuery(getConfigRugnuxOptions()); + const {data: rugnux} = useQuery({ + ...getRugnuxStatusOptions(), + refetchInterval: 1000, + enabled: rugnuxSettings?.enable === true, + }); + if (s === undefined) return alert("error", "Not connected to Jungfraujoch instance; check if jfjoch_broker is running"); if (s.message === undefined) return alert("info", ""); + if (rugnuxSettings?.enable === true && rugnux !== undefined && rugnux.state !== 'Idle' + && COLLECTION_MESSAGE.test(s.message)) { + const failed = rugnux.state === 'Failed'; + const sev = failed && s.message_severity === 'success' ? "warning" : severity(s.message_severity); + return alert(sev, `${s.message} · rugnux analysis: ${rugnux.state.toLowerCase()}`); + } + return alert(severity(s.message_severity), s.message); } diff --git a/frontend/src/components/MeasurementStatistics.tsx b/frontend/src/components/MeasurementStatistics.tsx index 4d882bf6e..6af42e009 100644 --- a/frontend/src/components/MeasurementStatistics.tsx +++ b/frontend/src/components/MeasurementStatistics.tsx @@ -66,7 +66,7 @@ function MeasurementStatistics({s}: MyProps) { ? s.bkg_estimate.toPrecision(7) : "-"} - Unit cell: + Unit cell (given): {s.unit_cell} diff --git a/frontend/src/components/RugnuxPage.tsx b/frontend/src/components/RugnuxPage.tsx index 890ff0e96..76f58327d 100644 --- a/frontend/src/components/RugnuxPage.tsx +++ b/frontend/src/components/RugnuxPage.tsx @@ -1,5 +1,5 @@ -import {Alert, Box, Chip, LinearProgress, Paper, Stack, Typography} from "@mui/material"; -import {indigo, lime, deepOrange, grey} from "@mui/material/colors"; +import {Alert, Box, LinearProgress, Paper, Stack, Typography} from "@mui/material"; +import {indigo, lime, grey} from "@mui/material/colors"; import {useQuery} from "@tanstack/react-query"; import {getConfigRugnuxOptions, getRugnuxStatusOptions} from "../client/@tanstack/react-query.gen"; import {rugnux_live_status} from "../client"; @@ -7,19 +7,13 @@ import {useTokenRequired} from "../auth"; import EmptyState from "./EmptyState"; import RugnuxTimeline from "./RugnuxTimeline"; import RugnuxSettings from "./RugnuxSettings"; -import {CutAlert, lastImageTime, ProbeAlert, StepTable, VerdictAlert} from "./RugnuxParts"; +import {CutAlert, lastImageTime, ProbeAlert, RunMessage, StateChip, StepTable, VerdictAlert} from "./RugnuxParts"; import BoltIcon from "@mui/icons-material/Bolt"; -const STATE_COLOUR: Record = { - Running: {bg: indigo[500], fg: "#fff"}, - Done: {bg: lime[600], fg: "rgba(0,0,0,.87)"}, - Failed: {bg: deepOrange[700], fg: "#fff"}, - Cancelled: {bg: grey[500], fg: "#fff"}, - Skipped: {bg: grey[400], fg: "rgba(0,0,0,.87)"}, -}; - -// The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile. -const KEY_ROW = /cell|space group|resolution|cc1\/2|r-?free|model/i; +// The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile, and +// space group and cell share a row. +const KEY_ROW = /cell|space group|resolution|signal|r-?free|model/i; +const PAIRED_ROW = /space group|unit cell/i; function Section({title, children}: { title: string, children: React.ReactNode }) { return ( @@ -34,35 +28,34 @@ function Section({title, children}: { title: string, children: React.ReactNode } } function Header({s}: { s: rugnux_live_status }) { - const colour = STATE_COLOUR[s.state] ?? STATE_COLOUR.Skipped; const latency = s.last_image_to_written_s; const lastImage = lastImageTime(s); return ( - - - - Rugnux live - - - - {s.images_received} / {s.images_expected} images - {lastImage !== undefined && ` · collected in ${lastImage.toFixed(1)} s`} - {` · ${s.elapsed_s.toFixed(1)} s since the first image`} - - {s.message && {s.message}} - - - - {latency !== undefined ? "Last image → all results written" : "Processing for"} - - - {(latency ?? s.elapsed_s).toFixed(1)} s - + + + + + Rugnux live + + + + {s.images_received} / {s.images_expected} images + {lastImage !== undefined && ` · collected in ${lastImage.toFixed(1)} s`} + {` · ${s.elapsed_s.toFixed(1)} s since the first image`} + + + + + {latency !== undefined ? "Last image → all results written" : "Processing for"} + + + {(latency ?? s.elapsed_s).toFixed(1)} s + + + ); } @@ -90,10 +83,10 @@ function SummaryTiles({s}: { s: rugnux_live_status }) { return ( {s.summary.map((row, i) => { - const key = KEY_ROW.test(row.label) || KEY_ROW.test(row.value); + const key = KEY_ROW.test(row.label); const short = row.value.length <= 24; return ( - { export const lastImageTime = (s: rugnux_live_status) => s.last_image_s ?? (s.last_image_to_written_s !== undefined ? s.elapsed_s - s.last_image_to_written_s : undefined); +const STATE_COLOUR: Record = { + Running: {bg: indigo[500], fg: "#fff"}, + Done: {bg: lime[600], fg: "rgba(0,0,0,.87)"}, + Failed: {bg: deepOrange[700], fg: "#fff"}, + Cancelled: {bg: grey[500], fg: "#fff"}, + Skipped: {bg: grey[400], fg: "rgba(0,0,0,.87)"}, +}; + +export function StateChip({state, large}: { state: string, large?: boolean }) { + const colour = STATE_COLOUR[state] ?? STATE_COLOUR.Skipped; + return ; +} + +// Why the run failed (or what it reports), as rugnux words it. +export function RunMessage({s, sx}: { s: rugnux_live_status, sx?: SxProps }) { + if (!s.message) + return null; + return ( + + {s.message} + + ); +} + export function CutAlert({s}: { s: rugnux_live_status }) { if (!s.cut) return null; diff --git a/frontend/src/components/RugnuxStatus.tsx b/frontend/src/components/RugnuxStatus.tsx index 053ae8e81..62bac798d 100644 --- a/frontend/src/components/RugnuxStatus.tsx +++ b/frontend/src/components/RugnuxStatus.tsx @@ -1,15 +1,20 @@ import {memo} from 'react'; -import {Alert, Box, LinearProgress, Typography} from "@mui/material"; +import {Alert, Box, Grid, LinearProgress, Stack, Table, TableBody, TableCell, TableRow, Typography} from "@mui/material"; import {useQuery} from "@tanstack/react-query"; import {getConfigRugnuxOptions, getRugnuxStatusOptions} from "../client/@tanstack/react-query.gen"; import {useTokenRequired} from "../auth"; import SettingsPanel from "./SettingsPanel"; -import {CutAlert, ProbeAlert, StepTable, VerdictAlert} from "./RugnuxParts"; +import RugnuxTimeline from "./RugnuxTimeline"; +import {CutAlert, ProbeAlert, RunMessage, StateChip, StepTable, VerdictAlert} from "./RugnuxParts"; -// Live rugnux processing of the last collection, as a dashboard card: the step it is on, the steps -// behind it with what they found, and once it is over the verdict. Nothing while rugnux has not run, -// and nothing - not even the status polling - while the mode is off (the settings are read once, on mount). +// The summary rows worth a glance on the dashboard; the Rugnux live tab has all of them. +const KEY_ROW = /space group|unit cell|resolution|completeness|signal/i; + +// Live rugnux processing of the last collection, as a full-width dashboard row: the timeline, the +// step it is on and the steps behind it, and once it is over the verdict with the key results. +// Nothing while rugnux has not run, and nothing - not even the status polling - while the mode is off +// (the settings are read once, on mount). Renders its own grid item, so an idle card leaves no gap. function RugnuxStatus() { const {data: settings} = useQuery(getConfigRugnuxOptions()); const enabled = settings?.enable === true; @@ -24,44 +29,70 @@ function RugnuxStatus() { return null; if (isError && tokenRequired) return ( - - Dataset token required (key button in the top bar) - + + + Dataset token required (key button in the top bar) + + ); if (isError || s === undefined || s.state === 'Idle') return null; const current = s.state === 'Running' && s.steps.length > 0 ? s.steps[s.steps.length - 1] : undefined; + const latency = s.last_image_to_written_s; + const keyRows = s.summary.filter(row => KEY_ROW.test(row.label)); return ( - - - {s.state} · {s.images_received} / {s.images_expected} images · {s.elapsed_s.toFixed(0)} s - - {s.message && {s.message}} - - + + + + + + + {s.images_received} / {s.images_expected} images · {s.elapsed_s.toFixed(0)} s + + {latency !== undefined && ( + + Last image → all written: {latency.toFixed(1)} s + + )} + + + + + - {current && ( - - {current.title} - - - )} - - {s.last_image_to_written_s !== undefined && ( - - Last image → all written: {s.last_image_to_written_s.toFixed(1)} s - - )} - - - - {s.warnings.map((w, i) => ( - {w} - ))} - + + + {current && ( + + {current.title} + + + )} + + {keyRows.length > 0 && ( + + + {keyRows.map((row, i) => ( + + + {row.label} + + {row.value} + + ))} + +
+ )} + {s.warnings.map((w, i) => {w})} +
+ +
+ +
+ ); } diff --git a/frontend/src/components/RugnuxTimeline.tsx b/frontend/src/components/RugnuxTimeline.tsx index 2eccfc1a1..37a5f5401 100644 --- a/frontend/src/components/RugnuxTimeline.tsx +++ b/frontend/src/components/RugnuxTimeline.tsx @@ -1,3 +1,4 @@ +import {useEffect, useRef, useState} from "react"; import {Box, Tooltip, Typography} from "@mui/material"; import {keyframes} from "@mui/system"; import {indigo, lime, lightBlue, orange, grey, deepOrange} from "@mui/material/colors"; @@ -25,6 +26,24 @@ const STEP_COLOURS = [indigo[500], indigo[300], indigo[700], lightBlue[700], ind const LABEL_WIDTH = 120; const LANE_HEIGHT = 64; const AXIS_HEIGHT = 28; +const MARKER_ROW = 28; + +// Rough rendered width of a label in px; enough to decide whether it fits and where it goes. +const textWidth = (text: string, pxPerChar: number) => text.length * pxPerChar; + +// Width of an element in px, kept up to date as the page resizes. +function useWidth() { + const ref = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + if (!ref.current) + return; + const observer = new ResizeObserver(entries => setWidth(entries[0].contentRect.width)); + observer.observe(ref.current); + return () => observer.disconnect(); + }, []); + return [ref, width] as const; +} function niceStep(span: number) { for (const s of [1, 2, 5, 10, 20, 30, 60, 120]) @@ -33,7 +52,25 @@ function niceStep(span: number) { return 300; } -function Marker({x, colour, label, top, dashed}: { x: number, colour: string, label: string, top: number, dashed?: boolean }) { +type MarkerSpec = { x: number, colour: string, label: string, dashed?: boolean }; + +// Labels that would overlap go up one row each: the first row whose last label ends left of this one. +function markerRows(markers: MarkerSpec[], width: number) { + const rowEnds: number[] = []; + return markers.map(m => { + const half = (textWidth(m.label, 8.5) + 16) / 2; + const centre = m.x / 100 * width; + let row = rowEnds.findIndex(end => centre - half > end + 6); + if (row < 0) { + row = rowEnds.length; + rowEnds.push(0); + } + rowEnds[row] = centre + half; + return row; + }); +} + +function Marker({x, colour, label, top, dashed}: MarkerSpec & { top: number }) { return ( + {text} +
+ ); +} + // deadline_s: the configured cutoff after the last image, 0 = none function RugnuxTimeline({s, deadline_s}: { s: rugnux_live_status, deadline_s: number }) { const lastImage = lastImageTime(s); @@ -55,17 +102,38 @@ function RugnuxTimeline({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu const rate = collectionEnd > 0 ? s.images_received / collectionEnd : 0; const expectedEnd = rate > 0 ? s.images_expected / rate : 0; - const deadline = lastImage !== undefined && deadline_s > 0 ? lastImage + deadline_s : undefined; + const done = s.state !== 'Running'; + // The deadline matters only while the analysis runs; once it is over the axis is the run's own. + const deadline = !done && lastImage !== undefined && deadline_s > 0 ? lastImage + deadline_s : undefined; const span = Math.max(s.elapsed_s, expectedEnd, deadline ?? 0, 10) * 1.06; const pct = (t: number) => 100 * Math.min(Math.max(t, 0), span) / span; const tick = niceStep(span); const ticks = Array.from({length: Math.floor(span / tick) + 1}, (_, i) => i * tick); - const done = s.state !== 'Running'; const latency = s.last_image_to_written_s; + const [laneRef, laneWidth] = useWidth(); + const detectorText = `${s.images_received} / ${s.images_expected} images${rate > 0 ? ` · ${rate.toFixed(0)} Hz` : ""}`; + const detectorInside = pct(collectionEnd) / 100 * laneWidth >= textWidth(detectorText, 11) + 48; + + const markers: MarkerSpec[] = []; + if (s.probe_s !== undefined) + markers.push({x: pct(s.probe_s), label: `probe ${s.probe_s.toFixed(1)} s`, + colour: s.probe.startsWith("no lattice") ? orange[800] : lightBlue[800]}); + if (lastImage !== undefined) + markers.push({x: pct(lastImage), dashed: true, colour: grey[800], label: "last image"}); + if (deadline !== undefined && s.cut !== 'deadline') + markers.push({x: pct(deadline), dashed: true, colour: deepOrange[700], label: `deadline ${deadline.toFixed(1)} s`}); + if (s.cut && s.cut_s !== undefined) + markers.push({x: pct(s.cut_s), colour: deepOrange[900], label: `${s.cut} cut ${s.cut_s.toFixed(1)} s`}); + if (!done) + markers.push({x: pct(s.elapsed_s), colour: "#d32f2f", label: "now"}); + markers.sort((a, b) => a.x - b.x); + const rows = markerRows(markers, laneWidth); + const rowCount = Math.max(1, ...rows.map(r => r + 1)); + return ( - + {["Detector", "Rugnux"].map((l, i) => ( @@ -74,7 +142,7 @@ function RugnuxTimeline({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu ))} - + {/* grid lines */} {ticks.map(t => ( - - {s.images_received} / {s.images_expected} images{rate > 0 && ` · ${rate.toFixed(0)} Hz`} - + {detectorInside && } + {!detectorInside && ( + + + + )} {collecting && expectedEnd > collectionEnd && ( = Math.min(textWidth(step.title, 8.5) + 12, 72); return ( @@ -118,10 +190,10 @@ function RugnuxTimeline({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu borderRadius: i === 0 ? "8px 0 0 8px" : 0, ...(open && !done && runningSx), ...(i === s.steps.length - 1 && {borderTopRightRadius: 8, borderBottomRightRadius: 8})}}> - + {showTitle && {step.title} - + } ); @@ -139,20 +211,7 @@ function RugnuxTimeline({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu {/* markers */} - {s.probe_s !== undefined && ( - - )} - {lastImage !== undefined && ( - - )} - {deadline !== undefined && s.cut !== 'deadline' && ( - - )} - {s.cut && s.cut_s !== undefined && ( - - )} - {!done && } + {markers.map((m, i) => )} {/* latency bracket */} {latency !== undefined && lastImage !== undefined && ( From 47132b75734f23f28d897464aa02f53072819e39 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 18 Oct 2026 12:46:21 +0200 Subject: [PATCH 2/2] frontend: rugnux live polish - failed run steps take the full row, Supercell tile not highlighted On the dashboard a run with no verdict, results or progress (a failed one) shows its step table across the whole row instead of in the right half beside nothing. On the Rugnux tab the summary tiles highlight "unit cell" rather than any label containing "cell". The image count drawn beside the detector bar is opaque, so the "now" line passing behind it no longer crosses the text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4 --- frontend/src/components/RugnuxPage.tsx | 2 +- frontend/src/components/RugnuxStatus.tsx | 9 ++++++--- frontend/src/components/RugnuxTimeline.tsx | 7 ++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/RugnuxPage.tsx b/frontend/src/components/RugnuxPage.tsx index 76f58327d..62aeaa923 100644 --- a/frontend/src/components/RugnuxPage.tsx +++ b/frontend/src/components/RugnuxPage.tsx @@ -12,7 +12,7 @@ import BoltIcon from "@mui/icons-material/Bolt"; // The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile, and // space group and cell share a row. -const KEY_ROW = /cell|space group|resolution|signal|r-?free|model/i; +const KEY_ROW = /unit cell|space group|resolution|signal|r-?free|model/i; const PAIRED_ROW = /space group|unit cell/i; function Section({title, children}: { title: string, children: React.ReactNode }) { diff --git a/frontend/src/components/RugnuxStatus.tsx b/frontend/src/components/RugnuxStatus.tsx index 62bac798d..a1dc88dd8 100644 --- a/frontend/src/components/RugnuxStatus.tsx +++ b/frontend/src/components/RugnuxStatus.tsx @@ -41,6 +41,8 @@ function RugnuxStatus() { const current = s.state === 'Running' && s.steps.length > 0 ? s.steps[s.steps.length - 1] : undefined; const latency = s.last_image_to_written_s; const keyRows = s.summary.filter(row => KEY_ROW.test(row.label)); + // a failed run has nothing beside its steps + const twoColumns = current !== undefined || !!s.verdict || keyRows.length > 0 || s.warnings.length > 0; return ( @@ -62,8 +64,9 @@ function RugnuxStatus() { - - + + {twoColumns && {current && ( {current.title} @@ -87,7 +90,7 @@ function RugnuxStatus() { )} {s.warnings.map((w, i) => {w})} - + } diff --git a/frontend/src/components/RugnuxTimeline.tsx b/frontend/src/components/RugnuxTimeline.tsx index 37a5f5401..22b499d20 100644 --- a/frontend/src/components/RugnuxTimeline.tsx +++ b/frontend/src/components/RugnuxTimeline.tsx @@ -84,10 +84,11 @@ function Marker({x, colour, label, top, dashed}: MarkerSpec & { top: number }) { ); } -function DetectorLabel({text}: { text: string }) { +// outside the bar the label is opaque, so the "now" line passing behind it does not cross the text +function DetectorLabel({text, outside}: { text: string, outside?: boolean }) { return ( {text} @@ -159,7 +160,7 @@ function RugnuxTimeline({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu {!detectorInside && ( - + )} {collecting && expectedEnd > collectionEnd && (