Merge branch 'rc187-live-fix-frontend' into rc186-merge2

This commit is contained in:
leonarski_f committed 2026-11-12 19:33:29 +01:00
commit 5202b5236d
13 files changed
+123 -55

No files matched your search

+4 -4
View File
@@ -28,7 +28,7 @@ import _ from "lodash";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import StatusBar from "./components/StatusBar";
import StatusBar, { StatusBarSpacer } from "./components/StatusBar";
import ErrorMessage from "./components/ErrorMessage";
import DataProcessingPlots from "./components/DataProcessingPlots";
import MeasurementStatistics from "./components/MeasurementStatistics";
@@ -164,7 +164,7 @@ function App() {
<DataProcessingPlots height={550} type={plot_type.BKG_ESTIMATE}/>
</Grid>
<Grid item xs={12} lg={4}>
<MeasurementStatistics s={s.measurement ?? {}}/>
<MeasurementStatistics s={s.measurement ?? {}} measuring={isMeasuring}/>
</Grid>
<RugnuxStatus/>
<Grid item xs={12} lg={8}>
@@ -270,7 +270,7 @@ function App() {
sx={{ width: drawerWidth, flexShrink: 0,
'& .MuiDrawer-paper': { width: drawerWidth, boxSizing: 'border-box',
bgcolor: drawer.bg, borderRight: 'none' } }}>
<Toolbar/>
<StatusBarSpacer/>
<Box sx={{ overflow: 'auto', pb: 9 }}>
{sections.map((sec, i) => (
<List key={i} subheader={sec.subheader
@@ -299,7 +299,7 @@ function App() {
</Drawer>
)}
<Box component="main" sx={{ flexGrow: 1, p: 3, pb: '84px', minWidth: 0 }}>
<Toolbar/>
<StatusBarSpacer/>
<ErrorMessage s={s.broker}/>
<Box sx={{ maxWidth: activeItem.wide ? 3400 : 1300, mx: 'auto', mt: 2 }}>
{activeItem.render?.()}
+4 -4
View File
@@ -94,7 +94,7 @@ function DataCollection({frame_time_us}: MyProps) {
value={tokens}
sx={{width:"90%"}}
/>
<Stack direction="row" spacing={2} sx={{width: '90%'}}>
<Stack direction={{xs: "column", sm: "row"}} spacing={2} sx={{width: '90%'}}>
<NumberTextField
label={"Number of images per trigger"}
callback={(val: number, err: boolean) => {
@@ -137,7 +137,7 @@ function DataCollection({frame_time_us}: MyProps) {
default={s.images_per_file}
/>
</Stack>
<Stack direction="row" spacing={2} sx={{width: '90%'}}>
<Stack direction={{xs: "column", sm: "row"}} spacing={2} sx={{width: '90%'}}>
<NumberTextField
label={"Beam X [pxl]"}
callback={(val: number, err: boolean) => {
@@ -196,7 +196,7 @@ function DataCollection({frame_time_us}: MyProps) {
</RadioGroup>
</FormControl>
{mode === 'rotation' && (
<Stack direction="row" spacing={2} sx={{width: '90%'}}>
<Stack direction={{xs: "column", sm: "row"}} spacing={2} sx={{width: '90%'}}>
<NumberTextField
label="Start angle"
callback={(val: number) => setGoniometer(prev => ({...prev, start: val}))}
@@ -220,7 +220,7 @@ function DataCollection({frame_time_us}: MyProps) {
</Stack>
)}
{mode === 'grid' && (
<Stack direction="row" spacing={2} sx={{width: '90%'}}>
<Stack direction={{xs: "column", sm: "row"}} spacing={2} sx={{width: '90%'}}>
<NumberTextField
label="Grid elements (fast dim.)"
callback={(val: number) => setGrid(prev => ({...prev, n_fast: val}))}
+16 -1
View File
@@ -3,11 +3,20 @@ import {ReactNode} from 'react';
import {useQuery} from "@tanstack/react-query";
import ShowChartIcon from "@mui/icons-material/ShowChart";
import {azint_unit, plot_type, plot_unit_x} from "../client";
import {getPreviewPlotOptions} from "../client/@tanstack/react-query.gen";
import {getConfigRugnuxOptions, getPreviewPlotOptions, getRugnuxStatusOptions} from "../client/@tanstack/react-query.gen";
import MultiLinePlotWrapper from "./MultiLinePlotWrapper";
import EmptyState from "./EmptyState";
import {useTokenRequired} from "../auth";
// Whether rugnux live is on a run: the per-image values then come with its first pass over the images,
// not image by image. The same queries the rugnux card polls, so this costs no request of its own.
function useRugnuxRunning() {
const {data: settings} = useQuery({...getConfigRugnuxOptions(), refetchInterval: 2000});
const enabled = settings?.enable === true;
const {data: s} = useQuery({...getRugnuxStatusOptions(), refetchInterval: 1000, enabled});
return enabled && s?.state === "Running";
}
type MyProps = {
type: plot_type;
binning: number;
@@ -108,6 +117,7 @@ function DataProcessingPlot({type, binning, angle, azint}: MyProps) {
});
const tokenRequired = useTokenRequired();
const rugnuxRunning = useRugnuxRunning();
if (isError
|| (plots === undefined)
@@ -116,6 +126,11 @@ function DataProcessingPlot({type, binning, angle, azint}: MyProps) {
return <EmptyState icon={<ShowChartIcon fontSize="inherit"/>}
text={tokenRequired ? "Dataset token required (key button in the top bar)" : "No plots available"}/>;
// a plot with no points (an image without a value is left out): nothing measured yet
if (plots.plot.every(d => d.y.length === 0))
return <EmptyState icon={<ShowChartIcon fontSize="inherit"/>}
text={rugnuxRunning ? "Per-image values come with rugnux's first pass over the images" : "No values yet"}/>;
let data: PlotlyData = [];
if ((plots.plot[0].z !== undefined)
&& (plots.plot[0].z.length > 0)) {
@@ -31,11 +31,12 @@ function DataProcessingPlots({type: initialType, height}: MyProps) {
setBinning(String(event.target.value).toString());
};
return <Paper style={{textAlign: 'center'}} sx={{ height: height, width: "100%" }}>
<Toolbar>
// On a phone the toolbar wraps, the select above the three controls, and the card takes the height it needs
return <Paper style={{textAlign: 'center'}} sx={{ height: {xs: "auto", md: height}, width: "100%" }}>
<Toolbar sx={{ flexWrap: {xs: "wrap", md: "nowrap"} }}>
<Grid container sx={{ minWidth: 500 }} >
<FormControl variant="standard" sx={{ m: 1, minWidth: 500 }}>
<Grid container sx={{ minWidth: {md: 500} }} >
<FormControl variant="standard" sx={{ m: 1, minWidth: {xs: 200, md: 500} }}>
<Select
value={tab}
@@ -124,7 +125,7 @@ function DataProcessingPlots({type: initialType, height}: MyProps) {
</FormControl>
</Grid>
</Toolbar>
<Box sx={{width:"95%", height: height - 150}} >
<Box sx={{width:"95%", height: {xs: 320, md: height - 150}}} >
<DataProcessingPlot type={type} binning={Number(binning)} angle={angleUnits}
azint={azintUnit}/>
</Box>
@@ -7,9 +7,14 @@ import SettingsPanel from "./SettingsPanel";
type MyProps = {
s: measurement_statistics,
measuring?: boolean,
};
function MeasurementStatistics({s}: MyProps) {
function MeasurementStatistics({s, measuring}: MyProps) {
// the efficiency is over the images expected, so while the collection runs it is its progress
const efficiency = s.collection_efficiency === undefined ? "-"
: measuring ? `${(100 * s.collection_efficiency).toFixed(0)} % so far`
: (Math.floor(s.collection_efficiency * 1000.0) / 1000.0).toFixed(3);
return (
<SettingsPanel title="Measurement statistics" sx={{ minHeight: 550 }}>
<TableContainer component={Paper} style={{marginLeft: "auto", marginRight: "auto"}}>
@@ -51,9 +56,8 @@ function MeasurementStatistics({s}: MyProps) {
</TableRow>
<TableRow>
<TableCell component="th" scope="row"> Data acquisition efficiency: </TableCell>
<TableCell align="right">{(s.collection_efficiency !== undefined)
? (Math.floor(s.collection_efficiency * 1000.0) / 1000.0).toFixed(3) : "-"}</TableCell>
<TableCell component="th" scope="row"> {measuring ? "Data acquisition progress:" : "Data acquisition efficiency:"} </TableCell>
<TableCell align="right">{efficiency}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row"> Indexing rate: </TableCell>
+17 -1
View File
@@ -4,9 +4,25 @@
// mapbox, geo, ...) which dominates the JS payload.
// Not using TypeScript, as plotly is not TypeScript :(
import {useEffect, useRef} from "react";
import createPlotlyComponent from "react-plotly.js/factory";
import Plotly from "plotly.js-cartesian-dist-min";
const Plot = createPlotlyComponent(Plotly);
const PlotlyPlot = createPlotlyComponent(Plotly);
// Plotly's `responsive` config follows the window only; a plot's own box also changes when the
// drawer opens or closes, so the plot is redrawn to its box whenever that resizes.
function Plot(props) {
const observer = useRef(null);
useEffect(() => () => observer.current?.disconnect(), []);
const onInitialized = (figure, graphDiv) => {
observer.current?.disconnect();
// a hidden plot cannot be resized: Plotly rejects, and the redraw comes with the next data
observer.current = new ResizeObserver(() => Plotly.Plots.resize(graphDiv).catch(() => {}));
observer.current.observe(graphDiv);
props.onInitialized?.(figure, graphDiv);
};
return <PlotlyPlot {...props} onInitialized={onInitialized}/>;
}
export default Plot;
+2 -2
View File
@@ -282,7 +282,7 @@ function PreviewImage({measuring, min_image_number, max_image_number}: MyProps)
<Grid container spacing={3}>
<Grid item xs={8}>
<Grid item xs={12} md={8}>
<Paper sx={{width: "100%", height: "100%", p: 1,
display: "flex", justifyContent: "center", alignItems: "center"}}>
{(!connectionError && (sUrl !== null)) ?
@@ -302,7 +302,7 @@ function PreviewImage({measuring, min_image_number, max_image_number}: MyProps)
}
</Paper>
</Grid>
<Grid item xs={4}>
<Grid item xs={12} md={4}>
<Paper sx={{width: "100%", height: "100%"}}>
<br/>
<Stack spacing={2} direction="column" sx={{
@@ -41,6 +41,12 @@ function RugnuxCollectionPlots({s, height}: { s: rugnux_live_status, height: num
const indexing = useCollectionPlot(plot_type.INDEXING_RATE, s);
const scales = s.frame_scales;
const images = Math.max(s.images_expected, s.images_received, 1);
// Before the first point: the images are in but not yet looked at - a serial run holds them for the
// pre-pass, a rotation run's per-image values come with rugnux's first pass
const noPoints = s.state !== "Running" ? "no images analysed"
: s.images_received === 0 ? "waiting for images"
: s.stills ? `${s.images_received.toLocaleString()} images held for the pre-pass`
: `${s.images_received.toLocaleString()} images in · spots and background with rugnux's first pass`;
const common = {paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", font, height, margin: PLOT_MARGIN,
showlegend: false, xaxis: {...axis, range: [0, images - 1], title: {text: "image", standoff: 2, font: {size: size - 1}}}};
@@ -58,7 +64,7 @@ function RugnuxCollectionPlots({s, height}: { s: rugnux_live_status, height: num
}));
return (
<Box sx={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.5}}>
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", sm: "1fr 1fr"}, gap: 1.5}}>
<Box sx={panelSx}>
{spots || bkg
? <Plot style={{width: "100%", height}} config={config}
@@ -72,7 +78,7 @@ function RugnuxCollectionPlots({s, height}: { s: rugnux_live_status, height: num
yaxis: {...axis, rangemode: "tozero", title: title("spots", pal.traces[0])},
yaxis2: {overlaying: "y", side: "right", rangemode: "tozero", showgrid: false, tickfont: {size: size - 1},
title: title("background", pal.traces[1])}}}/>
: <Empty text="waiting for images"/>}
: <Empty text={noPoints}/>}
</Box>
<Box sx={panelSx}>
{s.stills
+4 -2
View File
@@ -118,7 +118,8 @@ function ModelTile({s, modelLoaded}: { s: rugnux_live_status, modelLoaded: boole
return <Tile value="—" caption="Model" tone="plain" tip="The run ended without placing the model - the steps and warnings say why."/>;
}
// column: the tiles as a rail, top to bottom, sharing the rail's height
// column: the tiles as a rail, top to bottom, sharing the rail's height (two or three to a row on a phone,
// where the rail is the width of the screen)
function RugnuxHero({s, modelLoaded, column}: { s: rugnux_live_status, modelLoaded: boolean, column?: boolean }) {
// the numbers are of a merge that exists: rotation - the run is over; serial - a provisional merge is in
const merged = s.d_min !== undefined || (s.shells !== undefined && s.shells.length > 0);
@@ -133,7 +134,8 @@ function RugnuxHero({s, modelLoaded, column}: { s: rugnux_live_status, modelLoad
return (
<Box sx={{display: "grid", gap: 1, minHeight: 0, ...(column
? {gridTemplateColumns: "1fr", gridAutoRows: "minmax(0, 1fr)", flexGrow: 1, "& > *": {display: "flex", flexDirection: "column", justifyContent: "center"}}
? {gridTemplateColumns: {xs: "repeat(2, minmax(0, 1fr))", sm: "repeat(3, minmax(0, 1fr))", md: "1fr"}, gridAutoRows: {md: "minmax(0, 1fr)"}, flexGrow: 1,
"& > *": {display: "flex", flexDirection: "column", justifyContent: "center"}}
: {gridTemplateColumns: {xs: "repeat(3, minmax(0, 1fr))", md: "repeat(9, minmax(0, 1fr))"}})}}>
<Tile value={num(o.d_min, x => `${x.toFixed(2)} Å`)} caption="Resolution" tone={pending}
tip="High-resolution limit of the merge (d_min)."/>
+9 -6
View File
@@ -1,4 +1,4 @@
import {Alert, Box, Button, Collapse, Paper, Stack, Typography, useTheme} from "@mui/material";
import {Alert, Box, Button, Collapse, Paper, Stack, Typography, useMediaQuery, useTheme} from "@mui/material";
import {keyframes} from "@mui/system";
import {useQuery} from "@tanstack/react-query";
import {getConfigRugnuxOptions, getRugnuxStatusOptions} from "../client/@tanstack/react-query.gen";
@@ -47,7 +47,7 @@ function phase(s: rugnux_live_status, pal: LivePalette): { label: string, colour
return lastImageTime(s) === undefined
? {label: "Collecting", colour: pal.collecting} : {label: "Processing", colour: pal.processing};
if (s.state === "Done")
return s.verdict === "OK" ? {label: "Done", colour: pal.ok}
return s.verdict === "OK" || !s.verdict ? {label: "Done", colour: pal.ok}
: s.verdict === "WARNINGS" ? {label: "Done · warnings", colour: pal.attention}
: {label: `Done · ${s.verdict.toLowerCase()}`, colour: pal.serious};
if (s.state === "Failed")
@@ -173,6 +173,9 @@ const RAIL = "clamp(200px, 12vw, 420px)";
function Dashboard({s, deadline_s, modelLoaded, measuring, pal}:
{ s: rugnux_live_status, deadline_s: number, modelLoaded: boolean, measuring: boolean, pal: LivePalette }) {
// Only on the fixed-height stage (xl) do the plots take their height from their box; below it the
// stage flows and the boxes take their height from the plots, so a measured height would feed back.
const stage = useMediaQuery(useTheme().breakpoints.up("xl"));
const [plotsRef, plotsHeight] = useHeight();
const [stripRef, stripHeight] = useHeight();
const later = s.stills ? "after the first merge" : "after the run";
@@ -196,11 +199,11 @@ function Dashboard({s, deadline_s, modelLoaded, measuring, pal}:
<Division sx={{flex: "0 1 auto", maxHeight: "40%", overflowY: "auto", "& > *": {flexShrink: 0}}}>
{s.triage && s.triage.length > 0 ? <RugnuxTriage items={s.triage}/> : <Later text={later}/>}
</Division>
<Division sx={{flex: "1 1 0"}} label={<>By resolution{s.stills && s.shells_merge && ` · ${s.shells_merge}`}</>}>
<Box ref={plotsRef} sx={{flexGrow: 1, minHeight: {xs: 400, xl: 0}, display: "flex"}}>
<Division sx={{flex: {xs: "none", xl: "1 1 0"}}} label={<>By resolution{s.stills && s.shells_merge && ` · ${s.shells_merge}`}</>}>
<Box ref={plotsRef} sx={{flexGrow: 1, minHeight: {xs: 200, xl: 0}, display: "flex"}}>
{s.shells && s.shells.length > 0
? <Box sx={{width: "100%"}}>
<RugnuxShellPlots shells={s.shells} height={Math.max(150, (plotsHeight - 16) / 2)}/>
<RugnuxShellPlots shells={s.shells} height={stage ? Math.max(150, (plotsHeight - 16) / 2) : 220}/>
</Box>
: <Later text={later}/>}
</Box>
@@ -208,7 +211,7 @@ function Dashboard({s, deadline_s, modelLoaded, measuring, pal}:
</Box>
<Division label="Collection" sx={{gridColumn: {md: "1 / 3", xl: "2 / 4"}, gridRow: {xl: 2}, ...hairline, minHeight: {xs: 200, xl: 0}}}>
<Box ref={stripRef} sx={{flexGrow: 1, minHeight: 0}}>
<RugnuxCollectionPlots s={s} height={Math.max(130, stripHeight)}/>
<RugnuxCollectionPlots s={s} height={stage ? Math.max(130, stripHeight) : 160}/>
</Box>
</Division>
</Box>
+4 -3
View File
@@ -124,8 +124,9 @@ function RugnuxProgress({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu
kinds.push({tag: tags[i], titles: [st.title], seconds: duration, open});
});
// On a phone the details go under the bars, which take the whole width
return (
<Box sx={{display: "grid", gridTemplateColumns: "72px minmax(0, 1fr) auto", columnGap: 2, alignItems: "start"}}>
<Box sx={{display: "grid", gridTemplateColumns: {xs: "72px minmax(0, 1fr)", sm: "72px minmax(0, 1fr) auto"}, columnGap: 2, alignItems: "start"}}>
{/* captions */}
<Box sx={{display: "grid", rowGap: `${GAP}px`}}>
<Typography sx={{...LABEL_SX, color: pal.collecting}}>Detector</Typography>
@@ -186,7 +187,7 @@ function RugnuxProgress({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu
</Box>
{/* details */}
<Box sx={{display: "grid", rowGap: `${GAP}px`, textAlign: "right"}}>
<Box sx={{display: "grid", rowGap: `${GAP}px`, textAlign: "right", gridColumn: {xs: "1 / 3", sm: "auto"}}}>
<Typography noWrap sx={{fontSize: "0.85rem", lineHeight: `${BAR}px`, color: "text.secondary", fontVariantNumeric: "tabular-nums"}}>
{detectorDetail}
</Typography>
@@ -197,7 +198,7 @@ function RugnuxProgress({s, deadline_s}: { s: rugnux_live_status, deadline_s: nu
{/* 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}}>
<Box sx={{gridColumn: {xs: "1 / 3", sm: "2 / 4"}, display: "flex", flexWrap: "wrap", gap: 0.75, mt: 0.25}}>
{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",
+5 -2
View File
@@ -5,12 +5,15 @@ import { SxProps, Theme } from "@mui/material/styles";
import { SystemStyleObject } from "@mui/system";
import { TypographyProps } from "@mui/material/Typography";
import { UploadSnackbar } from "./useUpload";
import { drawerColours } from "../theme";
/** The panel title row: a light-blue (sidebar) band with a lime left accent, echoing
* the selected sidebar entry so the cards feel part of the same system. Exported so
* the selected sidebar entry so the cards feel part of the same system; at night the
* selected entry's indigo tint, which the light text reads on. Exported so
* DetectorSettings (which doesn't use SettingsPanel) can match. */
export const panelHeaderSx = {
px: 3, py: 1.75, bgcolor: indigo[50], borderLeft: "4px solid", borderColor: "secondary.main",
px: 3, py: 1.75, borderLeft: "4px solid", borderColor: "secondary.main",
bgcolor: (t: Theme) => t.palette.mode === "dark" ? drawerColours("dark").selected : indigo[50],
};
/** Uniform look for the inline <b> section labels used inside the config panels:
+36 -19
View File
@@ -132,6 +132,18 @@ function TokenButton() {
</>;
}
// On a phone the three action buttons do not fit beside the status pill: they get a second, dense row of
// the bar, and the wordmark goes. The spacer below reserves the bar's height for the page.
const phoneRow = {xs: "flex", sm: "none"} as const;
const desktopRow = {xs: "none", sm: "flex"} as const;
export function StatusBarSpacer() {
return <>
<Toolbar/>
<Toolbar variant="dense" sx={{display: phoneRow}}/>
</>;
}
function StatusBar({s, onMenuClick, mode, onModeClick}: MyProps) {
// Pick each button's tier from the current state, so the one action that makes
// sense right now is lime and the rest recede.
@@ -142,6 +154,27 @@ function StatusBar({s, onMenuClick, mode, onModeClick}: MyProps) {
: (st === 'Idle') ? 'active' : 'off';
const pedestalActive = st === 'Idle';
const actions = (
<Stack direction="row" spacing={1}>
<ButtonWithSnackbar
text={"Cancel"}
path={"/cancel"}
{...TIER[cancelTier]}
/>
<ButtonWithSnackbar
text={"Calibrate"}
path={"/pedestal"}
disabled={!pedestalActive}
{...(pedestalActive ? TIER.active : TIER.off)}
/>
<ButtonWithSnackbar
text={"Initialize"}
path={"/initialize"}
{...TIER[initTier]}
/>
</Stack>
);
return <AppBar sx={{zIndex: (theme) => theme.zIndex.drawer + 1}}>
<Toolbar>
{onMenuClick && (
@@ -150,7 +183,7 @@ function StatusBar({s, onMenuClick, mode, onModeClick}: MyProps) {
</IconButton>
)}
<Typography variant="h6" component="div" noWrap
sx={{flexGrow: 0.5, fontWeight: 500, letterSpacing: '.02em'}}>
sx={{flexGrow: 0.5, fontWeight: 500, letterSpacing: '.02em', display: {xs: 'none', sm: 'block'}}}>
Jungfraujoch
</Typography>
@@ -167,25 +200,9 @@ function StatusBar({s, onMenuClick, mode, onModeClick}: MyProps) {
</Tooltip>
)}
<TokenButton/>
<Stack direction="row" spacing={1} sx={{ml: 1}}>
<ButtonWithSnackbar
text={"Cancel"}
path={"/cancel"}
{...TIER[cancelTier]}
/>
<ButtonWithSnackbar
text={"Calibrate"}
path={"/pedestal"}
disabled={!pedestalActive}
{...(pedestalActive ? TIER.active : TIER.off)}
/>
<ButtonWithSnackbar
text={"Initialize"}
path={"/initialize"}
{...TIER[initTier]}
/>
</Stack>
<Box sx={{ml: 1, display: desktopRow}}>{actions}</Box>
</Toolbar>
<Toolbar variant="dense" sx={{display: phoneRow, justifyContent: 'center', pb: 0.5}}>{actions}</Toolbar>
</AppBar>
}