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..62aeaa923 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 = /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 }) { 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..a1dc88dd8 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,73 @@ 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)); + // a failed run has nothing beside its steps + const twoColumns = current !== undefined || !!s.verdict || keyRows.length > 0 || s.warnings.length > 0; 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} - ))} - + + {twoColumns && + {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..22b499d20 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 +103,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 +143,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 +191,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 +212,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 && (