frontend: shared SettingsPanel + typed upload with error display

Generalize the expert-panel boilerplate (container + title + widgets +
upload button) and surface real upload errors via hey-api.

- SettingsPanel: consistent padded Paper + Typography title (one place to
  size it) + optional Upload button + result snackbar; PanelTitle and
  UploadSnackbarView exported for panels with bespoke layouts (DetectorSettings
  grid, ROI)
- useUpload(mutation): wraps a generated hey-api mutation, shows the server's
  error message on failure (errorMessage handles 400 string / 500 error_message)
- "unsaved changes" dot on panel titles, derived from the existing
  last-downloaded snapshot (dirty = !isEqual(value, lastDownloaded))
- fix PixelMask: TIFF upload was swallowing errors; now shows them
- config panels (FileWriter, ImageFormat, Detector, AzInt, DarkMask, Indexing,
  Instrument, ZeroMQ, ROI, DetectorSelection) upload through the typed SDK
  instead of ButtonWithSnackbar's raw fetch; status/display panels share the
  same shell for consistent spacing
- ButtonWithSnackbar now only powers bodyless action buttons (pedestal/cancel/
  initialize, start/trigger, deactivate)

Build (tsc + vite) passes; dev server transforms all modules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 22:04:55 +02:00
co-authored by Claude Opus 4.8
parent c000b872bb
commit 59da040e30
17 changed files with 311 additions and 284 deletions
+13 -27
View File
@@ -1,12 +1,13 @@
import {ChangeEvent, memo, useEffect, useState} from 'react';
import Paper from '@mui/material/Paper';
import {FormControlLabel, Checkbox, Stack, Radio, RadioGroup, Typography} from "@mui/material";
import NumberTextField from "./NumberTextField";
import {azim_int_settings} from "../client";
import {putConfigAzimIntMutation} from "../client/@tanstack/react-query.gen";
import _ from "lodash";
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import FormControl from "@mui/material/FormControl";
import SettingsPanel from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: azim_int_settings
@@ -29,6 +30,7 @@ function AzIntSettings({s: serverS}: MyProps) {
const [lowQError, setLowQError] = useState(false);
const [highQError, setHighQError] = useState(false);
const [qSpacingError, setQSpacingError] = useState(false);
const { submit, pending, snackbar } = useUpload(putConfigAzimIntMutation());
useEffect(() => {
if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) {
@@ -39,15 +41,13 @@ function AzIntSettings({s: serverS}: MyProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverS]);
return <Paper style={{textAlign: 'center'}} sx={{ width: '100%'}}>
<br/>
<Stack spacing={3} sx={{
justifyContent: "center",
alignItems: "center",
marginLeft: '8%',
marginRight: '8%',
}}>
<div><strong>Azimuthal integration settings </strong></div>
const dirty = !_.isEqual(s, lastDownloadedS);
return (
<SettingsPanel title="Azimuthal integration" dirty={dirty}
onUpload={() => submit(s)}
uploadDisabled={pending || (s.high_q_recipA <= s.low_q_recipA) || highQError || lowQError || qSpacingError}
snackbar={snackbar}>
<Stack spacing={2} direction="row">
<NumberTextField
default={1.0}
@@ -139,22 +139,8 @@ function AzIntSettings({s: serverS}: MyProps) {
</RadioGroup>
</FormControl>
<ButtonWithSnackbar
color={"primary"}
path={"/config/azim_int"}
input={JSON.stringify(s)}
method={"PUT"}
text={"Upload"}
disabled={
(s.high_q_recipA <= s.low_q_recipA)
|| highQError
|| lowQError
|| qSpacingError
}
/>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
export default memo(AzIntSettings);
+9 -23
View File
@@ -1,10 +1,11 @@
import {memo, useEffect, useState} from 'react';
import Paper from '@mui/material/Paper';
import {Checkbox, FormControlLabel, Stack} from '@mui/material';
import _ from 'lodash';
import NumberTextField from './NumberTextField';
import ButtonWithSnackbar from './ButtonWithSnackbar';
import { dark_mask_settings } from '../client';
import { putConfigDarkMaskMutation } from '../client/@tanstack/react-query.gen';
import SettingsPanel from './SettingsPanel';
import { useUpload } from './useUpload';
type MyProps = {
s?: dark_mask_settings;
@@ -28,6 +29,7 @@ function DarkMaskSettings({s: serverS}: MyProps) {
const [numberOfFramesOld, setNumberOfFramesOld] = useState(1000);
const [maxPixelCountErr, setMaxPixelCountErr] = useState(false);
const [maxFramesWithSignalErr, setMaxFramesWithSignalErr] = useState(false);
const { submit, pending, snackbar } = useUpload(putConfigDarkMaskMutation());
useEffect(() => {
if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) {
@@ -51,17 +53,11 @@ function DarkMaskSettings({s: serverS}: MyProps) {
maxPixelCountErr ||
maxFramesWithSignalErr;
const dirty = !_.isEqual(s, lastDownloadedS);
return (
<Paper style={{ textAlign: 'center' }} sx={{ width: '100%' }}>
<br />
<Stack
spacing={3}
sx={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<strong>Dark data collection settings for mask</strong>
<SettingsPanel title="Dark mask" dirty={dirty}
onUpload={() => submit(s)} uploadDisabled={pending || hasError()} snackbar={snackbar}>
<div>(DECTRIS detectors only)</div>
<FormControlLabel
@@ -161,17 +157,7 @@ function DarkMaskSettings({s: serverS}: MyProps) {
}}
/>
</Stack>
<ButtonWithSnackbar
path={'/config/dark_mask'}
color={'primary'}
method={'PUT'}
disabled={hasError()}
text={'Upload'}
input={JSON.stringify(s)}
/>
</Stack>
<br />
</Paper>
</SettingsPanel>
);
}
@@ -1,8 +1,8 @@
import {ChangeEvent, memo, useEffect, useState} from 'react';
import Paper from '@mui/material/Paper';
import {Grid, Slider, Switch, Typography} from "@mui/material";
import {putConfigSpotFinding, spot_finding_settings} from "../client";
import SettingsPanel from "./SettingsPanel";
import _ from "lodash";
type MyProps = {
@@ -91,12 +91,12 @@ function DataProcessingSettings({s: serverS, update}: MyProps) {
apply({...s, high_res_gap_Q_recipA: v});
};
return <Paper style={{textAlign: 'center'}} sx={{ height: 800, width: '100%' }}>
return (
<SettingsPanel title="Spot finding parameters" sx={{ height: 800 }}>
<Grid container spacing={0}>
<Grid item xs={1}/>
<Grid item xs={10}>
<br/><strong>Spot finding parameters</strong><br/><br/>
<Switch onChange={enableSpotFindingToggle} checked={s.enable}/>
Spot finding
<br/><br/>
@@ -183,7 +183,8 @@ function DataProcessingSettings({s: serverS, update}: MyProps) {
</Grid>
<Grid item xs={1}/>
</Grid>
</Paper>
</SettingsPanel>
);
}
export default memo(DataProcessingSettings);
+15 -20
View File
@@ -1,13 +1,15 @@
import {memo, ReactNode, useEffect, useState} from 'react';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import {Stack, Table, TableBody, TableCell, TableContainer, TableRow} from "@mui/material";
import {Table, TableBody, TableCell, TableContainer, TableRow} from "@mui/material";
import Paper from "@mui/material/Paper";
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import {detector_list, detector_list_element} from "../client";
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import {putConfigSelectDetectorMutation} from "../client/@tanstack/react-query.gen";
import SettingsPanel from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: detector_list
@@ -39,6 +41,7 @@ const default_detector_element: detector_list_element = {
function DetectorSelection({s}: MyProps) {
const [choice, setChoice] = useState("0");
const { submit, pending, snackbar } = useUpload(putConfigSelectDetectorMutation());
useEffect(() => {
if (s !== undefined)
@@ -105,13 +108,14 @@ function DetectorSelection({s}: MyProps) {
return v;
};
return <Paper style={{textAlign: 'center'}} sx={{width: '100%'}}>
<br/>
<Stack spacing={3} sx={{
justifyContent: "center",
alignItems: "center"
}}>
<strong>Detector selection </strong>
const dirty = s !== undefined && Number(choice) !== s.current_id;
return (
<SettingsPanel title="Detector selection" dirty={dirty}
uploadText="Select detector"
onUpload={() => submit({ id: Number(choice) })}
uploadDisabled={pending || s === undefined}
snackbar={snackbar}>
{detector_info()}
@@ -128,17 +132,8 @@ function DetectorSelection({s}: MyProps) {
{detector_options().map(d => (<MenuItem value={d.id}> {d.name} </MenuItem>))}
</Select>
</FormControl>
<ButtonWithSnackbar
color={"primary"}
path={"/config/select_detector"}
input={JSON.stringify({id: Number(choice)})}
method={"PUT"}
text={"Select detector"}
disabled={s === undefined}
/>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
export default memo(DetectorSelection);
+21 -17
View File
@@ -7,9 +7,13 @@ import InputLabel from "@mui/material/InputLabel";
import Select, {SelectChangeEvent} from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import {detector_settings, detector_timing} from "../client";
import {putConfigDetectorMutation} from "../client/@tanstack/react-query.gen";
import NumberTextField from "./NumberTextField";
import _ from "lodash";
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import Button from "@mui/material/Button";
import {PanelTitle, UploadSnackbarView} from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: detector_settings
@@ -46,6 +50,7 @@ function DetectorSettings({s: serverS}: MyProps) {
const [eigerThresholdErr, setEigerThresholdErr] = useState(false);
const [eigerThresholdKeVOld, setEigerThresholdKeVOld] = useState(6.0);
const [downloadCounter, setDownloadCounter] = useState(0);
const { submit, pending, snackbar } = useUpload(putConfigDetectorMutation());
useEffect(() => {
if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) {
@@ -139,13 +144,15 @@ function DetectorSettings({s: serverS}: MyProps) {
setS(prev => ({...prev, jungfrau_storage_cell_count: Number(event.target.value)}));
};
const dirty = !_.isEqual(s, lastDownloadedS);
return <Paper style={{textAlign: 'center'}} sx={{ height: 1150, width: '100%' }}>
<Grid container>
<Grid item xs={1}/>
<Grid item xs={10}>
<br/><strong>Detector settings </strong>
<br/><PanelTitle title="Detector settings" dirty={dirty}/>
<br/><br/><br/>
<NumberTextField start_val={s.frame_time_us}
label={"Internal frame time"}
@@ -410,22 +417,18 @@ function DetectorSettings({s: serverS}: MyProps) {
<Grid item xs={1}/>
<Grid item xs={1}/>
<Grid item xs={10}>
<ButtonWithSnackbar
color={"primary"}
path={"/config/detector"}
disabled={countTimeErr
|| frameTimeErr
|| internalFrameGeneratorImagesErr
|| detectorTriggerDelayErr
|| storageCellDelayErr
|| pedestalG0FramesErr
|| pedestalG1FramesErr
|| pedestalG2FramesErr
|| pedestalMinImageCountErr}
text={"Upload"}
input={JSON.stringify(s)}
method={"PUT"}
/>&nbsp;&nbsp;
<Button color="primary" variant="contained" disableElevation
onClick={() => submit(s)}
disabled={pending
|| countTimeErr
|| frameTimeErr
|| internalFrameGeneratorImagesErr
|| detectorTriggerDelayErr
|| storageCellDelayErr
|| pedestalG0FramesErr
|| pedestalG1FramesErr
|| pedestalG2FramesErr
|| pedestalMinImageCountErr}>Upload</Button>&nbsp;&nbsp;
<ButtonWithSnackbar
color={"primary"}
path={"/deactivate"}
@@ -435,6 +438,7 @@ function DetectorSettings({s: serverS}: MyProps) {
</Grid>
<Grid item xs={1}/>
</Grid>
<UploadSnackbarView snackbar={snackbar}/>
</Paper>
}
+6 -12
View File
@@ -1,8 +1,9 @@
import {memo} from 'react';
import Paper from '@mui/material/Paper';
import {Stack, Table, TableBody, TableCell, TableContainer, TableRow,} from "@mui/material";
import {Table, TableBody, TableCell, TableContainer, TableRow,} from "@mui/material";
import {detector_status, detector_state, detector_power_state} from "../client";
import SettingsPanel from "./SettingsPanel";
type MyProps = {
s?: detector_status
@@ -42,14 +43,8 @@ function DetectorStatus({s}: MyProps) {
high_voltage_V: []
};
return <Paper style={{textAlign: 'center'}} sx={{width: '100%'}}>
<br/>
<Stack spacing={3} sx={{
justifyContent: "center",
alignItems: "center"
}}>
<strong>Detector status</strong>
return (
<SettingsPanel title="Detector status">
<TableContainer component={Paper}
style={{marginLeft: "auto", marginRight: "auto"}}
@@ -85,9 +80,8 @@ function DetectorStatus({s}: MyProps) {
</Table>
</TableContainer>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
export default memo(DetectorStatus);
+12 -20
View File
@@ -1,13 +1,14 @@
import {memo, useEffect, useState} from 'react';
import Paper from '@mui/material/Paper';
import {Checkbox, FormControlLabel, FormGroup, Select, Stack} from "@mui/material";
import {Checkbox, FormControlLabel, FormGroup, Select} from "@mui/material";
import {file_writer_format, file_writer_settings} from "../client";
import {putConfigFileWriterMutation} from "../client/@tanstack/react-query.gen";
import _ from "lodash";
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import MenuItem from "@mui/material/MenuItem";
import InputLabel from "@mui/material/InputLabel";
import FormControl from "@mui/material/FormControl";
import SettingsPanel from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: file_writer_settings
@@ -31,6 +32,7 @@ function stringToEnum(value: string): file_writer_format {
function FileWriterSettings({s: serverS}: MyProps) {
const [s, setS] = useState<file_writer_settings>(default_file_writer_settings);
const [lastDownloadedS, setLastDownloadedS] = useState<file_writer_settings>(default_file_writer_settings);
const { submit, pending, snackbar } = useUpload(putConfigFileWriterMutation());
useEffect(() => {
if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) {
@@ -40,13 +42,11 @@ function FileWriterSettings({s: serverS}: MyProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverS]);
return <Paper style={{textAlign: 'center'}} sx={{ width: '100%'}}>
<br/>
<Stack spacing={3} sx={{
justifyContent: "center",
alignItems: "center",
}}>
<div><strong>File Writer settings </strong></div>
const dirty = !_.isEqual(s, lastDownloadedS);
return (
<SettingsPanel title="File writer" dirty={dirty}
onUpload={() => submit(s)} uploadDisabled={pending} snackbar={snackbar}>
<FormGroup>
<FormControlLabel
control={
@@ -80,16 +80,8 @@ function FileWriterSettings({s: serverS}: MyProps) {
<MenuItem value={file_writer_format.NO_FILE_WRITTEN}>No files saved</MenuItem>
</Select>
</FormControl>
<ButtonWithSnackbar
color={"primary"}
path={"/config/file_writer"}
input={JSON.stringify(s)}
method={"PUT"}
text={"Upload"}
/>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
export default memo(FileWriterSettings);
+11 -21
View File
@@ -1,15 +1,16 @@
import {ChangeEvent, memo, useEffect, useState} from 'react';
import {Checkbox, FormControlLabel, FormGroup, List, ListItem, Stack, Tooltip} from "@mui/material";
import Paper from "@mui/material/Paper";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import Select, {SelectChangeEvent} from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import {image_format_settings} from "../client";
import {putConfigImageFormatMutation} from "../client/@tanstack/react-query.gen";
import NumberTextField from "./NumberTextField";
import _ from "lodash";
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import SettingsPanel from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: image_format_settings
@@ -59,6 +60,7 @@ function ImageFormatSettings({s: serverS}: MyProps) {
const [jungfrauConversionFactorErr, setJungfrauConversionFactorErr] = useState(false);
const [jungfrauConversionFactorOld, setJungfrauConversionFactorOld] = useState(12.4);
const [downloadCounter, setDownloadCounter] = useState(0);
const { submit, pending, snackbar } = useUpload(putConfigImageFormatMutation());
useEffect(() => {
if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) {
@@ -103,13 +105,11 @@ function ImageFormatSettings({s: serverS}: MyProps) {
setS(prev => ({...prev, signed_output: val}));
};
return <Paper style={{textAlign: 'center'}} sx={{width: '100%'}}>
<br/>
<Stack spacing={3} sx={{
justifyContent: "center",
alignItems: "center",
}}>
<strong>Output image format settings </strong>
const dirty = !_.isEqual(s, lastDownloadedS);
return (
<SettingsPanel title="Output image format" dirty={dirty}
onUpload={() => submit(s)} uploadDisabled={pending || jungfrauConversionFactorErr} snackbar={snackbar}>
<FormGroup>
<FormControlLabel control={
<Checkbox checked={s.geometry_transform}
@@ -254,18 +254,8 @@ function ImageFormatSettings({s: serverS}: MyProps) {
}}
/>
<ButtonWithSnackbar
path={"/config/image_format"}
color={"primary"}
method={"PUT"}
disabled={jungfrauConversionFactorErr}
text={"Upload"}
input={JSON.stringify(s)}
/>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
export default memo(ImageFormatSettings);
@@ -5,8 +5,9 @@
import {memo} from 'react';
import Paper from '@mui/material/Paper';
import {Stack, Table, TableBody, TableCell, TableContainer, TableRow} from "@mui/material";
import {Table, TableBody, TableCell, TableContainer, TableRow} from "@mui/material";
import {image_pusher_status, image_pusher_type} from "../client";
import SettingsPanel from "./SettingsPanel";
type MyProps = {
s?: image_pusher_status
@@ -34,10 +35,7 @@ function renderPusherType(type?: image_pusher_type): string {
function ImagePusherStatus({s}: MyProps) {
return (
<Paper style={{textAlign: 'center'}} sx={{width: '100%'}}>
<br/>
<Stack spacing={2} sx={{justifyContent: "center", alignItems: "center"}}>
<strong>Image pusher status</strong>
<SettingsPanel title="Image pusher status" spacing={2}>
<TableContainer component={Paper} sx={{width: '90%'}} style={{marginLeft: "auto", marginRight: "auto"}}>
<Table size="small">
<TableBody>
@@ -60,9 +58,7 @@ function ImagePusherStatus({s}: MyProps) {
</TableBody>
</Table>
</TableContainer>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
+11 -21
View File
@@ -1,15 +1,16 @@
import {memo, useEffect, useState} from 'react';
import {Stack, Typography, FormControlLabel, Checkbox} from "@mui/material";
import Paper from "@mui/material/Paper";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import Select, {SelectChangeEvent} from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import {broker_status, geom_refinement_algorithm, indexing_algorithm, indexing_settings} from "../client";
import {putConfigIndexingMutation} from "../client/@tanstack/react-query.gen";
import NumberTextField from "./NumberTextField";
import _ from "lodash";
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import SettingsPanel from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: indexing_settings
@@ -48,6 +49,7 @@ function IndexingSettings({s: serverS, status}: MyProps) {
const [unitCellDistToleranceError, setUnitCellDistToleranceError] = useState(false);
const [rotationIndexingMinAngularRangeDegError, setRotationIndexingMinAngularRangeDegError] = useState(false);
const [rotationIndexingAngularStrideDegError, setRotationIndexingAngularStrideDegError] = useState(false);
const { submit, pending, snackbar } = useUpload(putConfigIndexingMutation());
useEffect(() => {
if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) {
@@ -86,13 +88,11 @@ function IndexingSettings({s: serverS, status}: MyProps) {
|| rotationIndexingAngularStrideDegError
|| rotationIndexingMinAngularRangeDegError;
return <Paper style={{textAlign: 'center'}} sx={{width: '100%'}}>
<br/>
<Stack spacing={3} sx={{
justifyContent: "center",
alignItems: "center",
}}>
<strong>Indexing settings </strong>
const dirty = !_.isEqual(s, lastDownloadedS);
return (
<SettingsPanel title="Indexing" dirty={dirty}
onUpload={() => submit(s)} uploadDisabled={pending || isError()} snackbar={snackbar}>
<b>Algorithm</b>
<FormControl sx = {{width: "80%"}}>
@@ -290,18 +290,8 @@ function IndexingSettings({s: serverS, status}: MyProps) {
}}
sx={{width: '80%'}}/>
<ButtonWithSnackbar
path={"/config/indexing"}
color={"primary"}
method={"PUT"}
disabled={isError()}
text={"Upload"}
input={JSON.stringify(s)}
/>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
export default memo(IndexingSettings);
+10 -22
View File
@@ -4,13 +4,13 @@ import {
Autocomplete,
Checkbox,
FormControlLabel, FormGroup,
Stack,
TextField
} from "@mui/material";
import Paper from "@mui/material/Paper";
import {instrument_metadata} from "../client";
import {putConfigInstrumentMutation} from "../client/@tanstack/react-query.gen";
import _ from "lodash";
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import SettingsPanel from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: instrument_metadata
@@ -37,6 +37,7 @@ const source_types : string[] = [
function InstrumentMetadata({s: serverS}: MyProps) {
const [s, setS] = useState<instrument_metadata>(default_instrument_metadata);
const [lastDownloadedS, setLastDownloadedS] = useState<instrument_metadata>(default_instrument_metadata);
const { submit, pending, snackbar } = useUpload(putConfigInstrumentMutation());
useEffect(() => {
if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) {
@@ -46,16 +47,11 @@ function InstrumentMetadata({s: serverS}: MyProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverS]);
return <Paper style={{textAlign: 'center'}} sx={{ width: '100%' }}>
<Stack spacing={3} sx={{
justifyContent: "center",
alignItems: "center",
marginLeft: '8%',
marginRight: '8%',
}}>
const dirty = !_.isEqual(s, lastDownloadedS);
<div><strong>Source and instrument metadata</strong></div>
return (
<SettingsPanel title="Source and instrument metadata" dirty={dirty}
onUpload={() => submit(s)} uploadDisabled={pending} snackbar={snackbar}>
<TextField id="source_name" label="Source name" variant="outlined"
fullWidth
onChange={(event: ChangeEvent<HTMLInputElement>) => {
@@ -104,16 +100,8 @@ function InstrumentMetadata({s: serverS}: MyProps) {
}}/>
} label="Electron source"/>
</FormGroup>
<ButtonWithSnackbar
color={"primary"}
path={"/config/instrument"}
input={JSON.stringify(s)}
method={"PUT"}
text={"Upload"}
/>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
export default memo(InstrumentMetadata);
@@ -1,20 +1,17 @@
import {memo} from 'react';
import Paper from '@mui/material/Paper';
import {Grid, Table, TableBody, TableCell, TableContainer, TableRow,} from "@mui/material";
import {Table, TableBody, TableCell, TableContainer, TableRow,} from "@mui/material";
import {measurement_statistics} from "../client";
import SettingsPanel from "./SettingsPanel";
type MyProps = {
s: measurement_statistics,
};
function MeasurementStatistics({s}: MyProps) {
return <Paper style={{textAlign: 'center'}} sx={{ height: 550, width: '100%' }}>
<Grid container spacing={0}>
<Grid item xs={1}/>
<Grid item xs={10}>
<br/><strong>Measurement statistics</strong><br/><br/>
return (
<SettingsPanel title="Measurement statistics" sx={{ height: 550 }}>
<TableContainer component={Paper} style={{marginLeft: "auto", marginRight: "auto"}}>
<Table size="small" aria-label="simple table">
<TableBody>
@@ -75,11 +72,8 @@ function MeasurementStatistics({s}: MyProps) {
</TableBody>
</Table>
</TableContainer>
<br/>
</Grid>
<Grid item xs={1}/>
</Grid>
</Paper>
</SettingsPanel>
);
}
export default memo(MeasurementStatistics);
+44 -47
View File
@@ -9,8 +9,10 @@ import {
TableContainer,
TableRow, TextField
} from "@mui/material";
import {putConfigUserMaskTiff, pixel_mask_statistics} from "../client";
import Button from "@mui/material/Button";
import {pixel_mask_statistics} from "../client";
import {putConfigUserMaskTiffMutation} from "../client/@tanstack/react-query.gen";
import SettingsPanel from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: pixel_mask_statistics
@@ -18,11 +20,7 @@ type MyProps = {
function PixelMask({s}: MyProps) {
const [file, setFile] = useState<File>();
const handleUpload = () => {
if (file)
putConfigUserMaskTiff({ body: file });
};
const { submit, pending, snackbar } = useUpload(putConfigUserMaskTiffMutation());
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
@@ -30,47 +28,46 @@ function PixelMask({s}: MyProps) {
}
};
return <Paper style={{textAlign: 'center'}} sx={{width: '100%'}}>
<br/><strong>Pixel mask</strong><br/><br/>
<Grid container spacing={0}>
<Grid item xs={1}/>
<Grid item xs={10}>
<TableContainer component={Paper} style={{marginLeft: "auto", marginRight: "auto"}}>
<Table size="small" aria-label="simple table">
<TableBody>
<TableRow>
<TableCell component="th" scope="row"> User mask: </TableCell>
<TableCell align="right">{s?.user_mask ?? "N/A"}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row"> Error pixels: </TableCell>
<TableCell align="right">{s?.wrong_gain ?? "N/A"}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row"> Noisy pixels: </TableCell>
<TableCell align="right">{s?.too_high_pedestal_rms ?? "N/A"}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
return (
<SettingsPanel title="Pixel mask"
onUpload={() => file && submit(file)}
uploadDisabled={!file || pending}
snackbar={snackbar}>
<Grid container spacing={0}>
<Grid item xs={1}/>
<Grid item xs={10}>
<TableContainer component={Paper} style={{marginLeft: "auto", marginRight: "auto"}}>
<Table size="small" aria-label="simple table">
<TableBody>
<TableRow>
<TableCell component="th" scope="row"> User mask: </TableCell>
<TableCell align="right">{s?.user_mask ?? "N/A"}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row"> Error pixels: </TableCell>
<TableCell align="right">{s?.wrong_gain ?? "N/A"}</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row"> Noisy pixels: </TableCell>
<TableCell align="right">{s?.too_high_pedestal_rms ?? "N/A"}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Grid>
<Grid item xs={1}/>
</Grid>
<Grid item xs={1}/>
</Grid>
<br/>
<a href="/config/mask.tiff">Download pixel mask</a><br/>
<a href="/config/user_mask.tiff">Download user pixel mask</a><br/><br/>
<TextField
type="file"
inputProps={{accept: '.tiff'}}
onChange={handleFileChange}
/><br/><br/>
<Button onClick={handleUpload}
variant="contained"
disableElevation
disabled={!file}
color={"primary"}>Upload</Button>
<br/><br/>
</Paper>
<div>
<a href="/config/mask.tiff">Download pixel mask</a><br/>
<a href="/config/user_mask.tiff">Download user pixel mask</a>
</div>
<TextField
type="file"
inputProps={{accept: '.tiff'}}
onChange={handleFileChange}
/>
</SettingsPanel>
);
}
export default memo(PixelMask);
+8 -9
View File
@@ -12,7 +12,9 @@ import Button from "@mui/material/Button";
import {DataGrid, GridActionsCellItem, GridColDef, GridRowModel} from "@mui/x-data-grid";
import DeleteIcon from '@mui/icons-material/DeleteOutlined';
import ErrorIcon from '@mui/icons-material/Error';
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import {putConfigRoiMutation} from "../client/@tanstack/react-query.gen";
import {UploadSnackbarView} from "./SettingsPanel";
import {useUpload} from "./useUpload";
import _ from "lodash";
type MyProps = {
@@ -49,6 +51,7 @@ function ROI({s: serverS}: MyProps) {
const [boxErr, setBoxErr] = useState<string>();
const [circleErr, setCircleErr] = useState<string>();
const [azimErr, setAzimErr] = useState<string | ReactNode>();
const { submit, pending, snackbar } = useUpload(putConfigRoiMutation());
useEffect(() => {
if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) {
@@ -386,19 +389,15 @@ function ROI({s: serverS}: MyProps) {
disableElevation>Add Circle ROI</Button>&nbsp;&nbsp;
<Button color="secondary" onClick={addAzimButton} variant="contained"
disableElevation>Add Azimuthal ROI</Button>&nbsp;&nbsp;
<ButtonWithSnackbar
path={"/config/roi"}
color={"primary"}
method={"PUT"}
disabled={(boxErr !== undefined) || (circleErr !== undefined)}
text={"Upload"}
input={JSON.stringify(roiStruct())}
/>
<Button color="primary" variant="contained" disableElevation
onClick={() => submit(roiStruct())}
disabled={pending || (boxErr !== undefined) || (circleErr !== undefined)}>Upload</Button>
<br/><br/>
</Grid>
<Grid item xs={1}/>
</Grid>
<UploadSnackbarView snackbar={snackbar}/>
</Paper>
}
+81
View File
@@ -0,0 +1,81 @@
import { ReactNode } from "react";
import { Alert, Box, Button, Paper, Snackbar, Stack, Tooltip, Typography } from "@mui/material";
import { SxProps, Theme } from "@mui/material/styles";
import { TypographyProps } from "@mui/material/Typography";
import { UploadSnackbar } from "./useUpload";
/** The upload-result snackbar, usable on its own by panels that don't use SettingsPanel. */
export function UploadSnackbarView({ snackbar }: { snackbar?: UploadSnackbar }) {
if (!snackbar) return null;
return (
<Snackbar open={snackbar.open} autoHideDuration={5000} onClose={snackbar.onClose}>
<Alert onClose={snackbar.onClose} variant="filled"
severity={snackbar.success ? "success" : "error"} sx={{ width: "100%" }}>
{snackbar.success ? "Ok!" : snackbar.message}
</Alert>
</Snackbar>
);
}
/** A title with an optional "unsaved changes" marker. */
export function PanelTitle({ title, variant = "h6", dirty }: { title: string; variant?: TypographyProps["variant"]; dirty?: boolean }) {
return (
<Typography variant={variant} component="div">
{title}
{dirty && (
<Tooltip title="Unsaved changes — upload to apply">
<Box component="span" sx={{ color: "warning.main", ml: 0.5 }}></Box>
</Tooltip>
)}
</Typography>
);
}
type SettingsPanelProps = {
title?: string;
titleVariant?: TypographyProps["variant"];
dirty?: boolean;
onUpload?: () => void;
uploadText?: string;
uploadDisabled?: boolean;
snackbar?: UploadSnackbar;
sx?: SxProps<Theme>;
spacing?: number;
children: ReactNode;
};
/**
* Consistent shell for the expert configuration panels: a padded Paper, a title
* (with an optional "unsaved changes" marker), the panel widgets, an optional
* Upload button, and the upload result snackbar.
*/
function SettingsPanel({
title,
titleVariant = "h6",
dirty,
onUpload,
uploadText = "Upload",
uploadDisabled,
snackbar,
sx,
spacing = 3,
children,
}: SettingsPanelProps) {
return (
<Paper sx={[{ width: "100%", p: 3, textAlign: "center" }, ...(Array.isArray(sx) ? sx : [sx])]}>
<Stack spacing={spacing} alignItems="center">
{title !== undefined && <PanelTitle title={title} variant={titleVariant} dirty={dirty} />}
{children}
{onUpload && (
<Button variant="contained" color="primary" disableElevation
onClick={onUpload} disabled={uploadDisabled}>
{uploadText}
</Button>
)}
</Stack>
<UploadSnackbarView snackbar={snackbar} />
</Paper>
);
}
export default SettingsPanel;
+14 -21
View File
@@ -1,12 +1,13 @@
import {memo, useEffect, useState} from 'react';
import Paper from '@mui/material/Paper';
import {FormControlLabel, Radio, RadioGroup, Stack} from "@mui/material";
import {FormControlLabel, Radio, RadioGroup} from "@mui/material";
import NumberTextField from "./NumberTextField";
import {zeromq_preview_settings} from "../client";
import {putConfigZeromqPreviewMutation} from "../client/@tanstack/react-query.gen";
import _ from "lodash";
import FormControl from "@mui/material/FormControl";
import ButtonWithSnackbar from "./ButtonWithSnackbar";
import SettingsPanel from "./SettingsPanel";
import {useUpload} from "./useUpload";
type MyProps = {
s?: zeromq_preview_settings
@@ -25,6 +26,7 @@ function ZeroMQPreview({s: serverS}: MyProps) {
const [downloadCounter, setDownloadCounter] = useState(0);
const [freqChoice, setFreqChoice] = useState("custom");
const [periodMs, setPeriodMs] = useState(default_zeromq_preview_settings.period_ms);
const { submit, pending, snackbar } = useUpload(putConfigZeromqPreviewMutation());
const update_freq = (choice: string) => {
switch (choice) {
@@ -69,13 +71,13 @@ function ZeroMQPreview({s: serverS}: MyProps) {
const empty = () : boolean =>
(s.socket_address === undefined) || (s.socket_address === "");
return <Paper style={{textAlign: 'center'}} sx={{ width: '100%'}}>
<br/>
<Stack spacing={3} sx={{
justifyContent: "center",
alignItems: "center",
}}>
<div><strong>ZeroMQ preview settings </strong></div>
const dirty = !_.isEqual(s, lastDownloadedS);
return (
<SettingsPanel title="ZeroMQ preview" dirty={dirty}
onUpload={() => submit(s)}
uploadDisabled={pending || ((freqChoice == "custom") && periodErr)}
snackbar={snackbar}>
<div>
{empty() ? "No preview available" : `ZeroMQ socket: ${s.socket_address}`}
</div>
@@ -103,17 +105,8 @@ function ZeroMQPreview({s: serverS}: MyProps) {
}}
disabled={freqChoice != "custom"}
fullWidth/>
<ButtonWithSnackbar
color={"primary"}
path={"/config/zeromq_preview"}
input={JSON.stringify(s)}
method={"PUT"}
text={"Upload"}
disabled={((freqChoice == "custom") && periodErr)}
/>
</Stack>
<br/>
</Paper>
</SettingsPanel>
);
}
export default memo(ZeroMQPreview);
+41
View File
@@ -0,0 +1,41 @@
import { useState } from "react";
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
/** Pull a human-readable message out of a hey-api error (400 = string, 500 = error_message). */
export function errorMessage(error: unknown): string {
if (typeof error === "string") return error;
if (error && typeof error === "object" && "msg" in error)
return String((error as { msg?: unknown }).msg ?? "Unknown error");
return "Upload failed";
}
export type UploadSnackbar = {
open: boolean;
success: boolean;
message: string;
onClose: () => void;
};
/**
* Wraps a generated hey-api mutation (e.g. putConfigFileWriterMutation()) and
* surfaces the result as a snackbar. submit(body) sends the typed body; on
* failure the server's error message is shown.
*/
export function useUpload<TError, TVars extends { body?: unknown }>(
mutationOptions: UseMutationOptions<unknown, TError, TVars>,
) {
const mutation = useMutation(mutationOptions);
const [open, setOpen] = useState(false);
const submit = (body: TVars["body"]) =>
mutation.mutate({ body } as TVars, { onSettled: () => setOpen(true) });
const snackbar: UploadSnackbar = {
open,
success: mutation.isSuccess,
message: mutation.error ? errorMessage(mutation.error) : "",
onClose: () => setOpen(false),
};
return { submit, pending: mutation.isPending, snackbar };
}