From 56ddfaef96bfcf725711385d18c4d6f4c0203a8d Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Thu, 18 Jun 2026 08:13:07 +0200 Subject: [PATCH] viewer: live detector status bar + dataset-follow sync mode Add a status-bar cluster, shown when connected over HTTP, that surfaces the broker state and live acquisition info: - broker state box (QProgressBar) with progress drawn as the bar fill, polled ~1 Hz on its own timer independent of image sync - Live/Disconnected connection badge (host:port in tooltip) - "+N new" badge and effective live-rate (Hz) readout All widgets are fixed-width and only blanked in place, so the bar never reflows when things appear/disappear. Add a third autoload mode (HTTPSyncDataset): manually selecting an image while following live now freezes the displayed image but keeps the dataset, plots and image count updating. Surfaced via a tri-state HTTP Sync button (off / live / data-only). Also fix GetBrokerStatus() dropping the message field and a couple of copy-pasted exception strings in JFJochHttpReader. Co-Authored-By: Claude Opus 4.8 --- reader/JFJochHttpReader.cpp | 32 +++++- reader/JFJochHttpReader.h | 4 + viewer/JFJochImageReadingWorker.cpp | 75 +++++++++++- viewer/JFJochImageReadingWorker.h | 16 ++- viewer/JFJochViewerStatusBar.cpp | 120 +++++++++++++++++++- viewer/JFJochViewerStatusBar.h | 34 +++++- viewer/JFJochViewerWindow.cpp | 12 ++ viewer/toolbar/JFJochViewerToolbarImage.cpp | 33 +++++- 8 files changed, 308 insertions(+), 18 deletions(-) diff --git a/reader/JFJochHttpReader.cpp b/reader/JFJochHttpReader.cpp index 30784ed9..dd383d27 100644 --- a/reader/JFJochHttpReader.cpp +++ b/reader/JFJochHttpReader.cpp @@ -50,7 +50,7 @@ BrokerStatus JFJochHttpReader::GetBrokerStatus() const { auto res = cli_cmd.Get("/status"); if (!res || res->status != httplib::StatusCode::OK_200) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, - "Could not get image buffer status"); + "Could not get broker status"); try { org::openapitools::server::model::Broker_status input = nlohmann::json::parse(res->body); @@ -59,6 +59,8 @@ BrokerStatus JFJochHttpReader::GetBrokerStatus() const { ret.gpu_count = input.getGpuCount(); if (input.progressIsSet()) ret.progress = input.getProgress(); + if (input.messageIsSet()) + ret.message = input.getMessage(); if (input.getState() == "Inactive") ret.state = JFJochState::Inactive; @@ -87,7 +89,7 @@ BrokerStatus JFJochHttpReader::GetBrokerStatus() const { return ret; } catch (std::exception &e) { throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, - "Could not parse image buffer status"); + "Could not parse broker status"); } } @@ -209,6 +211,30 @@ void JFJochHttpReader::ReadURL(const std::string &url) { SetStartMessage(UpdateDataset_i()); } +std::shared_ptr JFJochHttpReader::RefreshDatasetIfChanged(int64_t &num_images_out) { + std::unique_lock ul(http_mutex); + + num_images_out = 0; + if (addr.empty()) + return {}; + + auto status = GetImageBufferStatus(); + num_images_out = status.max_image_number + 1; + + // Re-fetch the dataset (start message + plots) only when the buffer actually changed, + // so the dataset-only follow mode does not hammer the broker with plot requests. + const bool buffer_changed = !last_image_buffer_counter.has_value() + || !status.current_counter.has_value() + || last_image_buffer_counter.value() != status.current_counter.value(); + last_image_buffer_counter = status.current_counter; + + if (!buffer_changed) + return {}; + + SetStartMessage(UpdateDataset_i()); + return GetDataset(); +} + bool JFJochHttpReader::LoadImage_i(std::shared_ptr &dataset, DataMessage &message, std::vector &buffer, @@ -342,7 +368,7 @@ std::vector JFJochHttpReader::GetPlot_i(const std::string &plot_type, flo return {}; } catch (nlohmann::json::parse_error &e) { throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, - "Could not parse image buffer status"); + "Could not parse plot " + plot_type); } } diff --git a/reader/JFJochHttpReader.h b/reader/JFJochHttpReader.h index 13dc6911..e558d98a 100644 --- a/reader/JFJochHttpReader.h +++ b/reader/JFJochHttpReader.h @@ -30,6 +30,10 @@ public: uint64_t GetNumberOfImages() const override; void Close() override; + // Refresh dataset/plots if the image buffer changed since the last poll (returns nullptr if + // unchanged). Always writes the current number of images to num_images_out. Does not load an image. + std::shared_ptr RefreshDatasetIfChanged(int64_t &num_images_out); + void UploadUserMask(const std::vector& mask); BrokerStatus GetBrokerStatus() const; diff --git a/viewer/JFJochImageReadingWorker.cpp b/viewer/JFJochImageReadingWorker.cpp index a10ded30..15a441d4 100644 --- a/viewer/JFJochImageReadingWorker.cpp +++ b/viewer/JFJochImageReadingWorker.cpp @@ -68,7 +68,8 @@ JFJochImageReadingWorker::JFJochImageReadingWorker(const SpotFindingSettings &se indexing_settings(experiment.GetIndexingSettings()), azint_settings(experiment.GetAzimuthalIntegrationSettings()) { qRegisterMetaType>("QVector"); - spot_finding_settings = settings;; + qRegisterMetaType("BrokerStatus"); + spot_finding_settings = settings; indexing = std::make_unique(indexing_settings); http_reader.Experiment(experiment); @@ -78,6 +79,10 @@ JFJochImageReadingWorker::JFJochImageReadingWorker(const SpotFindingSettings &se autoload_timer->setInterval(autoload_interval); connect(autoload_timer, &QTimer::timeout, this, &JFJochImageReadingWorker::AutoLoadTimerExpired); + status_timer = new QTimer(this); + status_timer->setInterval(status_interval); + connect(status_timer, &QTimer::timeout, this, &JFJochImageReadingWorker::StatusTimerExpired); + file_open_retry_timer = new QTimer(this); file_open_retry_timer->setSingleShot(true); connect(file_open_retry_timer, &QTimer::timeout, this, &JFJochImageReadingWorker::FileOpenRetryTimerExpired); @@ -171,6 +176,8 @@ void JFJochImageReadingWorker::LoadFile_i(const QString &filename, qint64 image_ http_reader.ReadURL(filename.toStdString()); total_images = http_reader.GetNumberOfImages(); dataset = http_reader.GetDataset(); + SetHttpConnected_i(true, filename); + status_timer->start(); if (image_number < 0) setAutoLoadMode_i(AutoloadMode::HTTPSync); else @@ -178,6 +185,8 @@ void JFJochImageReadingWorker::LoadFile_i(const QString &filename, qint64 image_ } else { http_mode = false; + status_timer->stop(); + SetHttpConnected_i(false, ""); if (retry) { pending_load.filename = filename; @@ -279,6 +288,10 @@ void JFJochImageReadingWorker::CloseFile() { else file_reader.Close(); + status_timer->stop(); + setAutoLoadMode_i(AutoloadMode::None); + SetHttpConnected_i(false, ""); + current_image_ptr.reset(); current_image.reset(); current_summation = 1; @@ -290,7 +303,12 @@ void JFJochImageReadingWorker::CloseFile() { void JFJochImageReadingWorker::LoadImage(int64_t image_number, int64_t summation) { QMutexLocker ul(&m); - setAutoLoadMode_i(AutoloadMode::None); + // Manually selecting an image while following live drops to dataset-only follow: + // the chosen image stays put while plots and image count keep updating. + if (autoload_mode == AutoloadMode::HTTPSync || autoload_mode == AutoloadMode::HTTPSyncDataset) + setAutoLoadMode_i(AutoloadMode::HTTPSyncDataset); + else + setAutoLoadMode_i(AutoloadMode::None); if ((image_number == current_image) && (current_summation == summation)) return; LoadImage_i(image_number, summation); @@ -366,6 +384,9 @@ void JFJochImageReadingWorker::LoadImage_i(int64_t image_number, int64_t summati autoload_timer->setInterval(autoload_interval); } } + + if (autoload_mode == AutoloadMode::HTTPSync) + emit liveRateChanged(autoload_interval > 0 ? 1000.0 / autoload_interval : 0.0); } logger.Info("Loaded image {} in {}/{} ms Autoload timer set to {} ms", image_number, duration_1, duration_2, autoload_interval); @@ -656,6 +677,10 @@ void JFJochImageReadingWorker::AutoLoadTimerExpired() { if (http_mode) LoadImage_i(-1 , 1); break; + case AutoloadMode::HTTPSyncDataset: + if (http_mode) + RefreshDatasetOnly_i(); + break; case AutoloadMode::Movie: { if (total_images == 0 || !current_image) return; @@ -668,12 +693,57 @@ void JFJochImageReadingWorker::AutoLoadTimerExpired() { } } +void JFJochImageReadingWorker::SetHttpConnected_i(bool connected, const QString &addr) { + // Assumes m locked! Only signals on a real change so the status bar does not flicker. + if (connected == http_connected) + return; + http_connected = connected; + emit httpConnectionChanged(connected, addr); +} + +void JFJochImageReadingWorker::RefreshDatasetOnly_i() { + // Assumes m locked! Updates dataset/plots and image count without touching the displayed image. + if (!http_mode) + return; + try { + int64_t num_images = 0; + auto dataset = http_reader.RefreshDatasetIfChanged(num_images); + SetHttpConnected_i(true, current_file); + + if (num_images != total_images) { + total_images = num_images; + if (current_image.has_value()) + emit imageNumberChanged(total_images, current_image.value()); + } + if (dataset) + emit datasetLoaded(dataset); + } catch (std::exception &e) { + logger.Debug("Dataset refresh failed: {}", e.what()); + SetHttpConnected_i(false, current_file); + } +} + +void JFJochImageReadingWorker::StatusTimerExpired() { + QMutexLocker locker(&m); + if (!http_mode) + return; + try { + BrokerStatus status = http_reader.GetBrokerStatus(); + SetHttpConnected_i(true, current_file); + emit brokerStatusUpdated(status); + } catch (std::exception &e) { + SetHttpConnected_i(false, current_file); + } +} + void JFJochImageReadingWorker::setAutoLoadMode_i(AutoloadMode in_mode) { autoload_mode = in_mode; if (autoload_mode == AutoloadMode::None) autoload_timer->stop(); else autoload_timer->start(); + if (autoload_mode != AutoloadMode::HTTPSync) + emit liveRateChanged(0.0); emit autoloadChanged(autoload_mode); } @@ -682,6 +752,7 @@ void JFJochImageReadingWorker::setAutoLoadMode(AutoloadMode mode) { switch (mode) { case AutoloadMode::HTTPSync: + case AutoloadMode::HTTPSyncDataset: if (http_mode) setAutoLoadMode_i(mode); else diff --git a/viewer/JFJochImageReadingWorker.h b/viewer/JFJochImageReadingWorker.h index fcf74d5d..07144f52 100644 --- a/viewer/JFJochImageReadingWorker.h +++ b/viewer/JFJochImageReadingWorker.h @@ -30,12 +30,15 @@ Q_DECLARE_METATYPE(AzimuthalIntegrationSettings) Q_DECLARE_METATYPE(UnitCell) Q_DECLARE_METATYPE(std::shared_ptr) +Q_DECLARE_METATYPE(BrokerStatus) class JFJochImageReadingWorker : public QObject { Q_OBJECT public: - enum class AutoloadMode {HTTPSync, Movie, None}; + // HTTPSync: follow latest image + dataset. HTTPSyncDataset: follow dataset/plots/image count + // but keep the displayed image frozen (entered by manually selecting an image while following). + enum class AutoloadMode {HTTPSync, HTTPSyncDataset, Movie, None}; Q_ENUM(AutoloadMode) private: mutable QMutex m; @@ -76,6 +79,11 @@ private: QTimer *autoload_timer; int autoload_interval = 500; // milliseconds + // Broker status polling (independent of image sync, runs whenever connected over HTTP) + QTimer *status_timer = nullptr; + int status_interval = 1000; // milliseconds + bool http_connected = false; + // Adaptive autoload interval based on recent load+analysis time MovingAverage autoload_ms_ma{8}; // window size (tune as needed) int autoload_interval_min_ms = 50; // 20 Hz is the top performance! @@ -103,6 +111,8 @@ private: void LoadFile_i(const QString &filename, qint64 image_number, qint64 summation, bool retry); void LoadImage_i(int64_t image_number, int64_t summation); + void RefreshDatasetOnly_i(); + void SetHttpConnected_i(bool connected, const QString &addr); void ReanalyzeImage_i(); void UpdateDataset_i(const std::optional& experiment); void UpdateAzint_i(const JFJochReaderDataset *dataset); @@ -120,6 +130,9 @@ signals: void autoloadChanged(AutoloadMode mode); void fileLoadError(QString title, QString message); void fileLoadRetryStatus(bool active, QString message); + void brokerStatusUpdated(BrokerStatus status); + void httpConnectionChanged(bool connected, QString addr); + void liveRateChanged(double hz); public: JFJochImageReadingWorker(const SpotFindingSettings &settings, const DiffractionExperiment& experiment, QObject *parent = nullptr); @@ -127,6 +140,7 @@ public: private slots: void AutoLoadTimerExpired(); + void StatusTimerExpired(); void FileOpenRetryTimerExpired(); public slots: diff --git a/viewer/JFJochViewerStatusBar.cpp b/viewer/JFJochViewerStatusBar.cpp index 445d48f7..680f7ede 100644 --- a/viewer/JFJochViewerStatusBar.cpp +++ b/viewer/JFJochViewerStatusBar.cpp @@ -3,8 +3,126 @@ #include "JFJochViewerStatusBar.h" -JFJochViewerStatusBar::JFJochViewerStatusBar(QWidget *parent) : QStatusBar(parent) {} +#include + +JFJochViewerStatusBar::JFJochViewerStatusBar(QWidget *parent) : QStatusBar(parent) { + // Added left-to-right within the permanent (right-aligned) area. + conn_label = new QLabel(this); + conn_label->setFixedWidth(100); + conn_label->setAlignment(Qt::AlignCenter); + addPermanentWidget(conn_label); + + state_box = new QProgressBar(this); + state_box->setFixedWidth(110); + state_box->setRange(0, 1000); + state_box->setValue(0); + state_box->setTextVisible(true); + state_box->setAlignment(Qt::AlignCenter); + state_box->setFormat(""); + addPermanentWidget(state_box); + + new_label = new QLabel(this); + new_label->setFixedWidth(70); + new_label->setAlignment(Qt::AlignCenter); + addPermanentWidget(new_label); + + rate_label = new QLabel(this); + rate_label->setFixedWidth(60); + rate_label->setAlignment(Qt::AlignCenter); + addPermanentWidget(rate_label); +} void JFJochViewerStatusBar::display(QString input) { showMessage(input, 60000); } + +void JFJochViewerStatusBar::setBrokerStatus(BrokerStatus status) { + QString text; + switch (status.state) { + case JFJochState::Inactive: text = "Inactive"; break; + case JFJochState::Idle: text = "Idle"; break; + case JFJochState::Measuring: text = "Measuring"; break; + case JFJochState::Error: text = "Error"; break; + case JFJochState::Busy: text = "Busy"; break; + case JFJochState::Calibration: text = "Calibration"; break; + } + state_box->setFormat(text); + + if (status.progress.has_value()) + state_box->setValue(std::clamp(static_cast(status.progress.value() * 1000.0f), 0, 1000)); + else + state_box->setValue(0); + + const QString chunk = (status.state == JFJochState::Error) ? "#d9534f" : "#5cb85c"; + state_box->setStyleSheet(QString( + "QProgressBar { border: 1px solid #aaa; border-radius: 2px; background: transparent; }" + "QProgressBar::chunk { background-color: %1; }").arg(chunk)); + + if (status.message.has_value() && !status.message.value().empty()) + state_box->setToolTip(QString::fromStdString(status.message.value())); + else + state_box->setToolTip(text); +} + +void JFJochViewerStatusBar::setHttpConnection(bool connected, QString addr) { + if (addr.isEmpty()) { + // No detector session at all -> blank the whole cluster (but keep the reserved space). + conn_label->setText(""); + conn_label->setToolTip(""); + conn_label->setStyleSheet(""); + state_box->setFormat(""); + state_box->setValue(0); + state_box->setStyleSheet(""); + state_box->setToolTip(""); + rate_label->setText(""); + new_label->setText(""); + new_label->setStyleSheet(""); + return; + } + + conn_label->setToolTip(addr); + if (connected) { + conn_label->setText("Live"); + conn_label->setStyleSheet("color: white; background-color: #5cb85c; border-radius: 3px;"); + } else { + conn_label->setText("Disconnected"); + conn_label->setStyleSheet("color: white; background-color: #d9534f; border-radius: 3px;"); + // Connection lost: clear live readouts but keep the badge so the user sees why. + state_box->setFormat(""); + state_box->setValue(0); + state_box->setToolTip(""); + rate_label->setText(""); + new_label->setText(""); + } +} + +void JFJochViewerStatusBar::setAutoloadMode(JFJochImageReadingWorker::AutoloadMode mode) { + autoload_mode = mode; + UpdateNewLabel(); +} + +void JFJochViewerStatusBar::setImageNumber(int64_t in_total_images, int64_t in_current_image) { + total_images = in_total_images; + current_image = in_current_image; + UpdateNewLabel(); +} + +void JFJochViewerStatusBar::setLiveRate(double hz) { + if (hz > 0.0) + rate_label->setText(QString::number(hz, 'f', 1) + " Hz"); + else + rate_label->setText(""); +} + +void JFJochViewerStatusBar::UpdateNewLabel() { + if (autoload_mode == JFJochImageReadingWorker::AutoloadMode::HTTPSyncDataset) { + const int64_t n = total_images - 1 - current_image; + if (n > 0) { + new_label->setText(QString("+%1 new").arg(n)); + new_label->setStyleSheet("color: #f0ad4e; font-weight: bold;"); + return; + } + } + new_label->setText(""); + new_label->setStyleSheet(""); +} diff --git a/viewer/JFJochViewerStatusBar.h b/viewer/JFJochViewerStatusBar.h index 59d2de0a..7592f523 100644 --- a/viewer/JFJochViewerStatusBar.h +++ b/viewer/JFJochViewerStatusBar.h @@ -4,14 +4,36 @@ #pragma once #include +#include +#include + +#include "../common/BrokerStatus.h" +#include "JFJochImageReadingWorker.h" class JFJochViewerStatusBar : public QStatusBar { + Q_OBJECT + + // Permanent (right-side) widgets. These are created once and never shown/hidden or resized, + // so the status bar never reflows; when not applicable they are just blanked in place. + QLabel *conn_label; // "Live" / "Disconnected" (host:port in tooltip) + QProgressBar *state_box; // Jungfraujoch state text, progress drawn as the bar fill + QLabel *new_label; // "+N new" when following the dataset with a frozen image + QLabel *rate_label; // effective live update rate + + JFJochImageReadingWorker::AutoloadMode autoload_mode = JFJochImageReadingWorker::AutoloadMode::None; + int64_t total_images = 0; + int64_t current_image = 0; + + void UpdateNewLabel(); + +public: + explicit JFJochViewerStatusBar(QWidget *parent = nullptr); + public slots: void display(QString input); -public: - JFJochViewerStatusBar(QWidget * parent = nullptr); - + void setBrokerStatus(BrokerStatus status); + void setHttpConnection(bool connected, QString addr); + void setAutoloadMode(JFJochImageReadingWorker::AutoloadMode mode); + void setImageNumber(int64_t total_images, int64_t current_image); + void setLiveRate(double hz); }; - - - diff --git a/viewer/JFJochViewerWindow.cpp b/viewer/JFJochViewerWindow.cpp index 903b7528..eab463ca 100644 --- a/viewer/JFJochViewerWindow.cpp +++ b/viewer/JFJochViewerWindow.cpp @@ -294,6 +294,18 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString connect(side_panel, &JFJochViewerSidePanel::writeStatusBar, statusbar, &JFJochViewerStatusBar::display); + // Detector connection / broker state / live readouts in the status bar + connect(reading_worker, &JFJochImageReadingWorker::brokerStatusUpdated, + statusbar, &JFJochViewerStatusBar::setBrokerStatus); + connect(reading_worker, &JFJochImageReadingWorker::httpConnectionChanged, + statusbar, &JFJochViewerStatusBar::setHttpConnection); + connect(reading_worker, &JFJochImageReadingWorker::autoloadChanged, + statusbar, &JFJochViewerStatusBar::setAutoloadMode); + connect(reading_worker, &JFJochImageReadingWorker::imageNumberChanged, + statusbar, &JFJochViewerStatusBar::setImageNumber); + connect(reading_worker, &JFJochImageReadingWorker::liveRateChanged, + statusbar, &JFJochViewerStatusBar::setLiveRate); + connect(metadataWindow, &JFJochViewerMetadataWindow::datasetUpdated, reading_worker, &JFJochImageReadingWorker::UpdateDataset); diff --git a/viewer/toolbar/JFJochViewerToolbarImage.cpp b/viewer/toolbar/JFJochViewerToolbarImage.cpp index ea6f99e9..b570194e 100644 --- a/viewer/toolbar/JFJochViewerToolbarImage.cpp +++ b/viewer/toolbar/JFJochViewerToolbarImage.cpp @@ -62,9 +62,11 @@ JFJochViewerToolbarImage::JFJochViewerToolbarImage(QWidget *parent) : QToolBar(p movie_button->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_M)); addWidget(movie_button); - autoload_button = new QPushButton("HTTP &Sync"); + autoload_button = new QPushButton("HTTP Sync"); autoload_button->setCheckable(true); autoload_button->setChecked(false); + // Fixed width so the tri-state glyph prefix never reflows the toolbar. + autoload_button->setFixedWidth(120); autoload_button->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_S)); addWidget(autoload_button); @@ -195,10 +197,18 @@ void JFJochViewerToolbarImage::setSummation(int val) { } void JFJochViewerToolbarImage::autoloadButtonPressed() { - if (autoload_button->isChecked()) - emit autoLoadButtonPressed(JFJochImageReadingWorker::AutoloadMode::HTTPSync); - else - emit autoLoadButtonPressed(JFJochImageReadingWorker::AutoloadMode::None); + // The button either starts/resumes live following or stops it; the data-only "frozen" state + // is entered automatically by manually selecting an image while following. + switch (autoload_mode) { + case JFJochImageReadingWorker::AutoloadMode::HTTPSync: + emit autoLoadButtonPressed(JFJochImageReadingWorker::AutoloadMode::None); + break; + case JFJochImageReadingWorker::AutoloadMode::HTTPSyncDataset: + case JFJochImageReadingWorker::AutoloadMode::Movie: + case JFJochImageReadingWorker::AutoloadMode::None: + emit autoLoadButtonPressed(JFJochImageReadingWorker::AutoloadMode::HTTPSync); + break; + } } void JFJochViewerToolbarImage::movieButtonPressed() { @@ -213,16 +223,29 @@ void JFJochViewerToolbarImage::reanalyzeButtonPressed() { } void JFJochViewerToolbarImage::setAutoloadMode(JFJochImageReadingWorker::AutoloadMode input) { + autoload_mode = input; switch (input) { case JFJochImageReadingWorker::AutoloadMode::HTTPSync: + autoload_button->setText("▣ HTTP Sync"); + autoload_button->setToolTip("Following live (image + data)"); + autoload_button->setChecked(true); + movie_button->setChecked(false); + break; + case JFJochImageReadingWorker::AutoloadMode::HTTPSyncDataset: + autoload_button->setText("◐ HTTP Sync"); + autoload_button->setToolTip("Following data only — image frozen (click to resume live)"); autoload_button->setChecked(true); movie_button->setChecked(false); break; case JFJochImageReadingWorker::AutoloadMode::Movie: + autoload_button->setText("HTTP Sync"); + autoload_button->setToolTip(""); autoload_button->setChecked(false); movie_button->setChecked(true); break; case JFJochImageReadingWorker::AutoloadMode::None: + autoload_button->setText("HTTP Sync"); + autoload_button->setToolTip(""); autoload_button->setChecked(false); movie_button->setChecked(false); break;