From 222fae10c74cc4bb7d368f555106ccfaca746beb Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Fri, 9 Jan 2026 22:04:23 +0100 Subject: [PATCH] initial commit --- .gitignore | 2 + CMakeLists.txt | 64 +++++ README.md | 99 +++++++ build.sh | 11 + include/config_manager.h | 33 +++ include/mainwindow.h | 42 +++ include/nvme_monitor.h | 34 +++ include/temperature_chart.h | 79 ++++++ install.sh | 21 ++ resources/nvme-monitor.desktop | 9 + resources/nvme-monitor.svg | 47 ++++ resources/resources.xml | 7 + resources/style.css | 4 + src/config_manager.cpp | 94 +++++++ src/main.cpp | 65 +++++ src/mainwindow.cpp | 320 ++++++++++++++++++++++ src/nvme_monitor.cpp | 164 ++++++++++++ src/temperature_chart.cpp | 469 +++++++++++++++++++++++++++++++++ 18 files changed, 1564 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100755 build.sh create mode 100644 include/config_manager.h create mode 100644 include/mainwindow.h create mode 100644 include/nvme_monitor.h create mode 100644 include/temperature_chart.h create mode 100644 install.sh create mode 100644 resources/nvme-monitor.desktop create mode 100644 resources/nvme-monitor.svg create mode 100644 resources/resources.xml create mode 100644 resources/style.css create mode 100644 src/config_manager.cpp create mode 100644 src/main.cpp create mode 100644 src/mainwindow.cpp create mode 100644 src/nvme_monitor.cpp create mode 100644 src/temperature_chart.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5acb669 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build +.vscode diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..c573fde --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.16) +project(nvme-monitor) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC") + +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED gtk4) +pkg_check_modules(CAIRO REQUIRED cairo) + +# Find glib-compile-resources tool +find_program(GLIB_COMPILE_RESOURCES glib-compile-resources REQUIRED) + +include_directories(${CMAKE_SOURCE_DIR}/include) +include_directories(${CMAKE_BINARY_DIR}) +include_directories(${GTK_INCLUDE_DIRS}) +include_directories(${CAIRO_INCLUDE_DIRS}) + +# Compile resources +add_custom_command( + OUTPUT ${CMAKE_BINARY_DIR}/resources.c + COMMAND ${GLIB_COMPILE_RESOURCES} + --generate-source + --sourcedir=${CMAKE_SOURCE_DIR}/resources + --target=${CMAKE_BINARY_DIR}/resources.c + ${CMAKE_SOURCE_DIR}/resources/resources.xml + DEPENDS ${CMAKE_SOURCE_DIR}/resources/nvme-monitor.svg + ${CMAKE_SOURCE_DIR}/resources/resources.xml +) + +set(SOURCES + src/main.cpp + src/mainwindow.cpp + src/nvme_monitor.cpp + src/temperature_chart.cpp + src/config_manager.cpp + ${CMAKE_BINARY_DIR}/resources.c +) + +set(HEADERS + include/mainwindow.h + include/nvme_monitor.h + include/temperature_chart.h + include/config_manager.h +) + +add_executable(nvme-monitor ${SOURCES} ${HEADERS}) + +target_link_libraries(nvme-monitor + ${GTK_LIBRARIES} + ${CAIRO_LIBRARIES} + m +) + +target_compile_options(nvme-monitor PRIVATE ${GTK_CFLAGS_OTHER}) +target_compile_options(nvme-monitor PRIVATE ${CAIRO_CFLAGS_OTHER}) + +# Install executable +install(TARGETS nvme-monitor + DESTINATION bin) + +# Install desktop file for system integration +install(FILES resources/nvme-monitor.desktop + DESTINATION share/applications) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e7c234 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# NVMe Temperature Monitor + +Jednoduchá GUI aplikace v C++, která v reálném čase zobrazuje teploty NVMe disků. + +## Požadavky + +- GTK 4 development balíčky +- Cairo development balíčky +- CMake >= 3.16 +- GCC/Clang kompilátor s C++17 +- `nvme-cli` balíček nainstalovaný + +### Instalace závislostí (Ubuntu/Debian) + +```bash +sudo apt-get install libgtk-4-dev libcairo2-dev cmake build-essential nvme-cli +``` + +### Instalace závislostí (Fedora/RHEL) + +```bash +sudo dnf install gtk4-devel cairo-devel cmake gcc-c++ nvme-cli +``` + +### Instalace závislostí (Arch) + +```bash +sudo pacman -S gtk4 cairo cmake base-devel nvme-cli +``` + +## Kompilace + +```bash +cd /home/kamma/projects/nvme-monitor +chmod +x build.sh +./build.sh +``` + +## Spuštění + +```bash +sudo ./build/nvme-monitor +``` + +**Poznámka:** Aplikace vyžaduje sudo oprávnění pro čtení informací z NVMe zařízení. + +## Funkce + +- ✅ Detekce všech NVMe disků +- ✅ Čtení teplot z hlavního senzoru a dalších senzorů +- ✅ Reálný čas graf s více řadami (každý disk v jiné barvě) +- ✅ Nastavitelný interval obnovení (100-10000 ms) +- ✅ Tlačítko pro vymazání grafu +- ✅ Status bar s informacemi o počtu zařízení a odečtech +- ✅ Cairo kreslení pro hladké grafy +- ✅ Mřížka a popisky os + +## Struktura projektu + +``` +nvme-monitor/ +├── CMakeLists.txt # Konfigurace CMake +├── src/ +│ ├── main.cpp # Vstupní bod aplikace +│ ├── mainwindow.cpp # Hlavní okno UI (GTK) +│ ├── nvme_monitor.cpp # Logika čtení NVME dat +│ └── temperature_chart.cpp # Komponenta grafu (cairo) +├── include/ +│ ├── mainwindow.h +│ ├── nvme_monitor.h +│ └── temperature_chart.h +└── build.sh # Build skript +``` + +## Použití + +1. Spusťte aplikaci se sudo: `sudo ./build/nvme-monitor` +2. Okno se otevře a začne automaticky číst teploty +3. Používejte spinner pro změnu intervalu obnovení +4. Stiskněte "Clear Data" pro vymazání grafu +5. Stiskněte "Quit" pro ukončení aplikace + +## Poznámky + +- Aplikace parsuje výstup z `nvme smart-log` příkazů +- Podporuje více NVME zařízení, každé v jiné barvě +- Parsuje jak hlavní teplotu, tak dodatečné senzory teploty +- Graf drží posledních 300 datových bodů pro lepší výkon +- Kreslení grafů je implementováno s Cairo pro lepší flexibilitu +- Mřížka se automaticky škáluje podle rozsahu teplot +3. Používejte spinner pro změnu intervalu obnovení +4. Stiskněte "Clear Data" pro vymazání grafu + +## Poznamky + +- Aplikace parsuje výstup z `nvme smart-log` příkazu +- Podporuje více NVME zařízení, každé v jiné barvě +- Parsuje jak hlavní teplotu, tak dodatečné senzory teploty +- Graph drží posledních 300 datových bodů pro lepší výkon diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..8ffee8f --- /dev/null +++ b/build.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Create build directory +mkdir -p build +cd build + +# Build project +cmake .. +make + +echo "Build complete! Run with: ./nvme-monitor" diff --git a/include/config_manager.h b/include/config_manager.h new file mode 100644 index 0000000..14a6c9e --- /dev/null +++ b/include/config_manager.h @@ -0,0 +1,33 @@ +#ifndef CONFIG_MANAGER_H +#define CONFIG_MANAGER_H + +#include + +class ConfigManager { +public: + ConfigManager(); + + // Load configuration + void load(); + + // Save configuration + void save(); + + // Getters and setters + int getWindowWidth() const { return windowWidth; } + int getWindowHeight() const { return windowHeight; } + int getPollingTime() const { return pollingTime; } + + void setWindowWidth(int w) { windowWidth = w; } + void setWindowHeight(int h) { windowHeight = h; } + void setPollingTime(int t) { pollingTime = t; } + +private: + std::string getConfigFilePath() const; + + int windowWidth; + int windowHeight; + int pollingTime; +}; + +#endif // CONFIG_MANAGER_H diff --git a/include/mainwindow.h b/include/mainwindow.h new file mode 100644 index 0000000..a0deb4b --- /dev/null +++ b/include/mainwindow.h @@ -0,0 +1,42 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include "nvme_monitor.h" +#include "temperature_chart.h" +#include "config_manager.h" + +class MainWindow { +public: + MainWindow(); + ~MainWindow(); + + void show(); + +private: + void setupUI(); + void updateTemperatures(); + void updateLegend(); + void saveWindowState(); + + static gboolean onDeleteWindow(GtkWidget *widget, gpointer userData); + static gboolean onUpdateTimer(gpointer userData); + static void onRefreshRateChanged(GtkSpinButton *spinButton, gpointer userData); + static void onClearButtonClicked(GtkButton *button, gpointer userData); + static void onQuitButtonClicked(GtkButton *button, gpointer userData); + + GtkWidget *window; + GtkWidget *statusLabel; + GtkSpinButton *refreshRateSpinBox; + TemperatureChart *chart; + NvmeMonitor *monitor; + ConfigManager *config; + guint timerID; + int refreshRateSec; + GtkWidget *legendBox; +}; + +// Global main loop reference for proper shutdown +extern GMainLoop *gMainLoop; + +#endif // MAINWINDOW_H diff --git a/include/nvme_monitor.h b/include/nvme_monitor.h new file mode 100644 index 0000000..ea139bf --- /dev/null +++ b/include/nvme_monitor.h @@ -0,0 +1,34 @@ +#ifndef NVME_MONITOR_H +#define NVME_MONITOR_H + +#include +#include +#include + +struct TemperatureData { + double temperature; + std::string device; + std::string sensorName; + long timestamp; +}; + +class NvmeMonitor { +public: + explicit NvmeMonitor(); + + // Get list of available NVMe devices + std::vector getAvailableDevices(); + + // Read temperatures from a specific device + std::map readTemperatures(const std::string &device); + + // Get all available devices and their temperatures + std::map> getAllTemperatures(); + +private: + std::vector scanDevices(); + std::map findHwmonDevices(); + double readTemperatureFromFile(const std::string &filePath); +}; + +#endif // NVME_MONITOR_H diff --git a/include/temperature_chart.h b/include/temperature_chart.h new file mode 100644 index 0000000..ab10bca --- /dev/null +++ b/include/temperature_chart.h @@ -0,0 +1,79 @@ +#ifndef TEMPERATURE_CHART_H +#define TEMPERATURE_CHART_H + +#include +#include +#include +#include +#include +#include + +struct DataPoint { + double temperature; + int64_t timestamp; +}; + +struct SeriesData { + std::vector points; + GdkRGBA color; + std::string name; +}; + +class TemperatureChart { +public: + TemperatureChart(GtkWidget *parent = nullptr); + ~TemperatureChart(); + + GtkWidget* getWidget() const { return drawingArea; } + + void addTemperatureData(const std::string &device, const std::string &sensor, + double temperature, int64_t timestamp); + void clear(); + void draw(); + + // Get list of devices with their colors + std::vector> getDeviceColors() const; + void drawChart(GtkDrawingArea *area, cairo_t *cr, int width, int height); + +private: + void setupColors(); + GdkRGBA getColorForDevice(const std::string &device); + void redraw(); + void updateThemeColors(); + + static gboolean onTick(gpointer userData); + static void onLeave(GtkEventControllerMotion *motion, gpointer userData); + + struct NearestPoint { + bool found; + double temperature; + int64_t timestamp; + std::string seriesName; + double distance; + }; + + NearestPoint findNearestDataPoint(double mouseX, double mouseY, int width, int height); + void showTooltip(double x, double y, const NearestPoint &point); + void hideTooltip(); + + GtkWidget *drawingArea; + GtkWidget *tooltipWindow; + GtkWidget *tooltipLabel; + std::map seriesMap; + std::map colorMap; + int maxDataPoints; + guint tickHandler; + double lastMouseX, lastMouseY; + + // Cairo drawing state + double minTemp, maxTemp; + int64_t minTime, maxTime; + static constexpr int MAX_TIME_MS = 600000; // 10 minutes in milliseconds + static constexpr double MIN_TEMP = 10.0; + static constexpr double MAX_TEMP = 110.0; + + // Theme colors + GdkRGBA bgColor, textColor, gridColor, axisColor; +}; + +#endif // TEMPERATURE_CHART_H diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..4dfb2b8 --- /dev/null +++ b/install.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Install script for nvme-monitor + +cd "$(dirname "$0")" || exit 1 + +echo "Building nvme-monitor..." +./build.sh + +if [ ! -f build/nvme-monitor ]; then + echo "Build failed!" + exit 1 +fi + +echo "Installing nvme-monitor..." +cd build +sudo cmake --install . + +echo "Installation complete!" +echo "Application icon is embedded in the executable" +echo "Run 'nvme-monitor' to start the application" + diff --git a/resources/nvme-monitor.desktop b/resources/nvme-monitor.desktop new file mode 100644 index 0000000..7510985 --- /dev/null +++ b/resources/nvme-monitor.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=NVMe Monitor +Comment=Monitor NVMe drive temperatures +Exec=nvme-monitor +Icon=nvme-monitor +Categories=Utility;System; +Terminal=false diff --git a/resources/nvme-monitor.svg b/resources/nvme-monitor.svg new file mode 100644 index 0000000..4677267 --- /dev/null +++ b/resources/nvme-monitor.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 110 + + + + 80 + + + + 50 + + + + 20 + + + + + + + + + NVMe + diff --git a/resources/resources.xml b/resources/resources.xml new file mode 100644 index 0000000..e554df8 --- /dev/null +++ b/resources/resources.xml @@ -0,0 +1,7 @@ + + + + nvme-monitor.svg + style.css + + diff --git a/resources/style.css b/resources/style.css new file mode 100644 index 0000000..dad86ce --- /dev/null +++ b/resources/style.css @@ -0,0 +1,4 @@ +/* Default application styles and icon */ +window { + /* Icon can be set via CSS if needed */ +} diff --git a/src/config_manager.cpp b/src/config_manager.cpp new file mode 100644 index 0000000..64cc65a --- /dev/null +++ b/src/config_manager.cpp @@ -0,0 +1,94 @@ +#include "config_manager.h" +#include +#include +#include +#include +#include +#include + +ConfigManager::ConfigManager() + : windowWidth(1200), windowHeight(700), pollingTime(3) +{ +} + +std::string ConfigManager::getConfigFilePath() const +{ + // Get the directory of the executable using /proc/self/exe + char path[1024]; + ssize_t len = readlink("/proc/self/exe", path, sizeof(path) - 1); + + if (len == -1) { + // Fallback to current directory + return "./nvme-monitor.conf"; + } + + path[len] = '\0'; + + // Get directory from full path + std::string fullPath(path); + size_t lastSlash = fullPath.find_last_of('/'); + if (lastSlash != std::string::npos) { + std::string exeDir = fullPath.substr(0, lastSlash); + return exeDir + "/nvme-monitor.conf"; + } + + return "./nvme-monitor.conf"; +} + +void ConfigManager::load() +{ + std::string configPath = getConfigFilePath(); + std::ifstream file(configPath); + + if (!file.is_open()) { + // Config file doesn't exist, use defaults + return; + } + + std::string line; + while (std::getline(file, line)) { + // Skip empty lines and comments + if (line.empty() || line[0] == '#') continue; + + size_t pos = line.find('='); + if (pos == std::string::npos) continue; + + std::string key = line.substr(0, pos); + std::string value = line.substr(pos + 1); + + // Trim whitespace + key.erase(key.find_last_not_of(" \t") + 1); + value.erase(0, value.find_first_not_of(" \t")); + value.erase(value.find_last_not_of(" \t") + 1); + + if (key == "window_width") { + windowWidth = std::stoi(value); + } else if (key == "window_height") { + windowHeight = std::stoi(value); + } else if (key == "polling_time") { + pollingTime = std::stoi(value); + } + } + + file.close(); +} + +void ConfigManager::save() +{ + std::string configPath = getConfigFilePath(); + + std::ofstream file(configPath); + + if (!file.is_open()) { + std::cerr << "Failed to save config to: " << configPath << std::endl; + return; + } + + file << "# NVMe Monitor Configuration\n"; + file << "# Auto-generated, do not edit manually\n\n"; + file << "window_width = " << windowWidth << "\n"; + file << "window_height = " << windowHeight << "\n"; + file << "polling_time = " << pollingTime << "\n"; + + file.close(); +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..12f1d81 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include +#include +#include "mainwindow.h" + +// Declaration from resources.c +extern "C" { + GResource *resources_get_resource(); +} + +GMainLoop *gMainLoop = nullptr; + +int main(int argc, char *argv[]) +{ + // Daemonize the process + pid_t pid = fork(); + + if (pid < 0) { + return 1; // Fork failed + } + + if (pid > 0) { + return 0; // Parent process exits + } + + // Child process continues as daemon + umask(0); + + // Create new session + if (setsid() < 0) { + return 1; + } + + // Change directory to root + chdir("/"); + + // Close standard file descriptors + int fd = open("/dev/null", O_RDWR); + if (fd != -1) { + dup2(fd, STDIN_FILENO); + dup2(fd, STDOUT_FILENO); + dup2(fd, STDERR_FILENO); + close(fd); + } + + gtk_init(); + + // Register resources containing the application icon + GResource *resource = resources_get_resource(); + g_resources_register(resource); + + gMainLoop = g_main_loop_new(nullptr, FALSE); + + MainWindow *window = new MainWindow(); + window->show(); + + // Run the main event loop + g_main_loop_run(gMainLoop); + g_main_loop_unref(gMainLoop); + + return 0; +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp new file mode 100644 index 0000000..4929067 --- /dev/null +++ b/src/mainwindow.cpp @@ -0,0 +1,320 @@ +#include "mainwindow.h" +#include +#include +#include +#include +#include +#include +#include + +MainWindow::MainWindow() + : window(nullptr), statusLabel(nullptr), refreshRateSpinBox(nullptr), + chart(nullptr), monitor(nullptr), config(nullptr), timerID(0), refreshRateSec(3) +{ + monitor = new NvmeMonitor(); + config = new ConfigManager(); + config->load(); + + refreshRateSec = config->getPollingTime(); + + setupUI(); + + // Set window size from config + gtk_window_set_default_size(GTK_WINDOW(window), config->getWindowWidth(), config->getWindowHeight()); + + // Start update timer + timerID = g_timeout_add(refreshRateSec * 1000, onUpdateTimer, this); + + // Initial load immediately + updateTemperatures(); +} + +MainWindow::~MainWindow() +{ + if (timerID) { + g_source_remove(timerID); + } + if (monitor) { + delete monitor; + } + if (chart) { + delete chart; + } + if (config) { + config->save(); + delete config; + } +} + +void MainWindow::setupUI() +{ + window = gtk_window_new(); + gtk_window_set_title(GTK_WINDOW(window), "NVMe Temperature Monitor"); + + // Set window icon - in GTK4 we load pixbuf and use it for the window display + GError *error = nullptr; + GdkPixbuf *pixbuf = gdk_pixbuf_new_from_resource("/org/kamma/nvme-monitor/nvme-monitor.svg", &error); + if (pixbuf) { + // GTK4 doesn't have gtk_window_set_icon, but we can store the pixbuf for use + // The icon will be visible in the window title bar and taskbar through the name + g_object_unref(pixbuf); + } else if (error) { + g_warning("Failed to load icon from resources: %s", error->message); + g_error_free(error); + } + + // Set icon name for fallback/system icon theme + gtk_window_set_icon_name(GTK_WINDOW(window), "nvme-monitor"); + + gtk_window_set_default_size(GTK_WINDOW(window), 1200, 700); + g_signal_connect(window, "close-request", G_CALLBACK(onDeleteWindow), this); + + // Main box + GtkWidget *mainBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + gtk_widget_set_margin_top(mainBox, 5); + gtk_widget_set_margin_start(mainBox, 5); + gtk_widget_set_margin_end(mainBox, 5); + gtk_widget_set_margin_bottom(mainBox, 5); + gtk_window_set_child(GTK_WINDOW(window), mainBox); + + // Control panel + GtkWidget *controlBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + + // Refresh rate label + GtkWidget *refreshLabel = gtk_label_new("Refresh interval (s):"); + gtk_box_append(GTK_BOX(controlBox), refreshLabel); + + // Refresh rate spinner + GtkAdjustment *adjustment = gtk_adjustment_new(refreshRateSec, 1, 60, 1, 5, 0); + refreshRateSpinBox = GTK_SPIN_BUTTON(gtk_spin_button_new(adjustment, 100, 0)); + g_signal_connect(refreshRateSpinBox, "value-changed", G_CALLBACK(onRefreshRateChanged), this); + gtk_box_append(GTK_BOX(controlBox), GTK_WIDGET(refreshRateSpinBox)); + + // Clear button + GtkWidget *clearButton = gtk_button_new_with_label("Clear Data"); + g_signal_connect(clearButton, "clicked", G_CALLBACK(onClearButtonClicked), this); + gtk_box_append(GTK_BOX(controlBox), clearButton); + + // Status label + statusLabel = gtk_label_new("Initializing..."); + gtk_label_set_xalign(GTK_LABEL(statusLabel), 0); + gtk_widget_set_hexpand(statusLabel, TRUE); + gtk_box_append(GTK_BOX(controlBox), statusLabel); + + // Quit button + GtkWidget *quitButton = gtk_button_new_with_label("Quit"); + g_signal_connect(quitButton, "clicked", G_CALLBACK(onQuitButtonClicked), this); + gtk_box_append(GTK_BOX(controlBox), quitButton); + + gtk_box_append(GTK_BOX(mainBox), controlBox); + + // Chart + chart = new TemperatureChart(); + gtk_box_append(GTK_BOX(mainBox), chart->getWidget()); + gtk_widget_set_vexpand(chart->getWidget(), TRUE); + + // Legend box + legendBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_widget_set_margin_top(legendBox, 10); + gtk_box_append(GTK_BOX(mainBox), legendBox); +} + +gboolean MainWindow::onDeleteWindow(GtkWidget *widget, gpointer userData) +{ + MainWindow *self = static_cast(userData); + self->saveWindowState(); + delete self; + + // Quit the main loop + if (gMainLoop) { + g_main_loop_quit(gMainLoop); + } + + return FALSE; +} + +gboolean MainWindow::onUpdateTimer(gpointer userData) +{ + MainWindow *self = static_cast(userData); + self->updateTemperatures(); + return TRUE; +} + +void MainWindow::onRefreshRateChanged(GtkSpinButton *spinButton, gpointer userData) +{ + MainWindow *self = static_cast(userData); + self->refreshRateSec = gtk_spin_button_get_value_as_int(spinButton); + self->config->setPollingTime(self->refreshRateSec); + + // Update timer + if (self->timerID) { + g_source_remove(self->timerID); + } + self->timerID = g_timeout_add(self->refreshRateSec * 1000, onUpdateTimer, self); +} + +void MainWindow::onClearButtonClicked(GtkButton *button, gpointer userData) +{ + MainWindow *self = static_cast(userData); + self->chart->clear(); +} + +void MainWindow::onQuitButtonClicked(GtkButton *button, gpointer userData) +{ + MainWindow *self = static_cast(userData); + self->saveWindowState(); + + // Quit the main loop + if (gMainLoop) { + g_main_loop_quit(gMainLoop); + } + + delete self; +} + +void MainWindow::saveWindowState() +{ + int width, height; + gtk_window_get_default_size(GTK_WINDOW(window), &width, &height); + + config->setWindowWidth(width > 0 ? width : 1200); + config->setWindowHeight(height > 0 ? height : 700); + config->setPollingTime(refreshRateSec); + config->save(); +} + +void MainWindow::updateTemperatures() +{ + auto allTemps = monitor->getAllTemperatures(); + + // Get current real time in milliseconds since epoch + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + int64_t currentTime = (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + int totalReadings = 0; + + for (auto &deviceEntry : allTemps) { + const std::string &device = deviceEntry.first; + const auto &temps = deviceEntry.second; + + for (const auto &tempEntry : temps) { + const std::string &sensorName = tempEntry.first; + double temperature = tempEntry.second; + + chart->addTemperatureData(device, sensorName, temperature, currentTime); + totalReadings++; + } + } + + // Update status label + std::time_t now = std::time(nullptr); + std::tm *timeinfo = std::localtime(&now); + std::ostringstream oss; + oss << "Updated: " << std::put_time(timeinfo, "%H:%M:%S") + << " | Devices: " << allTemps.size() + << " | Total readings: " << totalReadings; + + gtk_label_set_text(GTK_LABEL(statusLabel), oss.str().c_str()); + + // Update legend + updateLegend(); +} + +void MainWindow::updateLegend() +{ + // Clear existing legend items + GtkWidget *child = gtk_widget_get_first_child(legendBox); + while (child) { + GtkWidget *next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(legendBox), child); + child = next; + } + + // Get device colors from chart + auto deviceColors = chart->getDeviceColors(); + + // Add legend items + for (const auto &pair : deviceColors) { + const std::string &device = pair.first; + const GdkRGBA &color = pair.second; + + // Create container for legend item + GtkWidget *itemBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + + // Create color box (drawing area with fixed color) + GtkWidget *colorBox = gtk_drawing_area_new(); + gtk_widget_set_size_request(colorBox, 20, 20); + + // Store color in user data + GdkRGBA *colorPtr = new GdkRGBA(color); + g_object_set_data_full(G_OBJECT(colorBox), "color", colorPtr, + [](gpointer data) { delete static_cast(data); }); + + gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(colorBox), + [](GtkDrawingArea *area, cairo_t *cr, int width, int height, gpointer data) { + GdkRGBA *color = static_cast(data); + cairo_set_source_rgba(cr, color->red, color->green, color->blue, color->alpha); + cairo_paint(cr); + + // Get theme colors from GTK settings + gboolean isDark = FALSE; + GtkSettings *settings = gtk_settings_get_default(); + if (settings) { + gchar *themeName = nullptr; + g_object_get(settings, "gtk-theme-name", &themeName, nullptr); + if (themeName) { + std::string theme(themeName); + isDark = (theme.find("dark") != std::string::npos || + theme.find("Dark") != std::string::npos); + g_free(themeName); + } + } + + // Try GSetting if not found in GTK settings + if (!isDark) { + GSettingsSchemaSource *source = g_settings_schema_source_get_default(); + if (source) { + GSettingsSchema *schema = g_settings_schema_source_lookup(source, "org.gnome.desktop.interface", FALSE); + if (schema) { + GSettings *gsettings = g_settings_new("org.gnome.desktop.interface"); + if (gsettings) { + gchar *gtkTheme = g_settings_get_string(gsettings, "gtk-theme"); + if (gtkTheme) { + std::string theme(gtkTheme); + isDark = (theme.find("dark") != std::string::npos || + theme.find("Dark") != std::string::npos); + g_free(gtkTheme); + } + g_object_unref(gsettings); + } + g_settings_schema_unref(schema); + } + } + } + + if (isDark) { + cairo_set_source_rgb(cr, 0.6, 0.6, 0.6); // Lighter gray for dark theme + } else { + cairo_set_source_rgb(cr, 0.3, 0.3, 0.3); // Darker gray for light theme + } + cairo_set_line_width(cr, 1); + cairo_rectangle(cr, 0, 0, width, height); + cairo_stroke(cr); + }, colorPtr, nullptr); + + gtk_box_append(GTK_BOX(itemBox), colorBox); + + // Create label with device name + GtkWidget *label = gtk_label_new(device.c_str()); + gtk_box_append(GTK_BOX(itemBox), label); + + gtk_box_append(GTK_BOX(legendBox), itemBox); + } + + gtk_widget_set_visible(legendBox, TRUE); +} + +void MainWindow::show() +{ + gtk_widget_set_visible(window, TRUE); +} diff --git a/src/nvme_monitor.cpp b/src/nvme_monitor.cpp new file mode 100644 index 0000000..3d689b5 --- /dev/null +++ b/src/nvme_monitor.cpp @@ -0,0 +1,164 @@ +#include "nvme_monitor.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +NvmeMonitor::NvmeMonitor() +{ +} + +std::vector NvmeMonitor::getAvailableDevices() +{ + return scanDevices(); +} + +double NvmeMonitor::readTemperatureFromFile(const std::string &filePath) +{ + std::ifstream file(filePath); + if (!file.is_open()) { + return 0.0; + } + + std::string line; + if (std::getline(file, line)) { + try { + // Temperature is in millidegrees Celsius, convert to Celsius + long tempMilliC = std::stol(line); + return tempMilliC / 1000.0; + } catch (const std::exception &e) { + std::cerr << "Failed to parse temperature from " << filePath << ": " << e.what() << std::endl; + } + } + file.close(); + return 0.0; +} + +std::map NvmeMonitor::findHwmonDevices() +{ + std::map hwmonMap; // Maps device name to hwmon path + + DIR* hwmonDir = opendir("/sys/class/hwmon"); + if (!hwmonDir) return hwmonMap; + + struct dirent* entry; + while ((entry = readdir(hwmonDir)) != nullptr) { + std::string hwmonName = entry->d_name; + + // Look for hwmon* directories + if (hwmonName.find("hwmon") == 0) { + std::string namePath = "/sys/class/hwmon/" + hwmonName + "/name"; + std::ifstream nameFile(namePath); + if (nameFile.is_open()) { + std::string deviceName; + if (std::getline(nameFile, deviceName)) { + // Remove trailing whitespace + deviceName.erase(deviceName.find_last_not_of(" \n\r\t") + 1); + + if (deviceName == "nvme") { + hwmonMap[hwmonName] = "/sys/class/hwmon/" + hwmonName; + } + } + nameFile.close(); + } + } + } + closedir(hwmonDir); + + return hwmonMap; +} + +std::vector NvmeMonitor::scanDevices() +{ + std::vector devices; + + DIR* devDir = opendir("/dev"); + if (!devDir) return devices; + + struct dirent* entry; + while ((entry = readdir(devDir)) != nullptr) { + std::string name = entry->d_name; + + // Only include main device entries (nvme0, nvme1, etc.), not namespaces or fabrics + if (name.find("nvme") == 0 && name.length() > 4) { + // Check if the rest after "nvme" contains only digits + bool onlyDigits = true; + for (size_t i = 4; i < name.length(); i++) { + if (!std::isdigit(name[i])) { + onlyDigits = false; + break; + } + } + if (onlyDigits) { + devices.push_back("/dev/" + name); + } + } + } + closedir(devDir); + + return devices; +} + +std::map NvmeMonitor::readTemperatures(const std::string &device) +{ + std::map temperatures; + + // Map hwmon devices to NVMe devices + auto hwmonMap = findHwmonDevices(); + + if (hwmonMap.empty()) { + std::cerr << "No NVMe hwmon devices found in /sys/class/hwmon" << std::endl; + return temperatures; + } + + // For now, read from the first NVMe device found + // In a more sophisticated implementation, we could map specific nvme devices to hwmon entries + auto firstHwmon = hwmonMap.begin(); + std::string hwmonPath = firstHwmon->second; + + // Read temperature sensors from hwmon + // temp1_input is typically the main sensor + double temp1 = readTemperatureFromFile(hwmonPath + "/temp1_input"); + if (temp1 > 0) { + temperatures["Main"] = temp1; + } + + // Check for additional temperature sensors (temp2, temp3, etc.) + for (int i = 2; i <= 10; i++) { + std::string tempPath = hwmonPath + "/temp" + std::to_string(i) + "_input"; + struct stat buffer; + if (stat(tempPath.c_str(), &buffer) == 0) { + double temp = readTemperatureFromFile(tempPath); + if (temp > 0) { + std::string sensorName = "Sensor " + std::to_string(i); + temperatures[sensorName] = temp; + } + } + } + + if (temperatures.empty()) { + std::cerr << "Failed to read temperatures from device: " << device << std::endl; + } + + return temperatures; +} + +std::map> NvmeMonitor::getAllTemperatures() +{ + std::map> allTemperatures; + + std::vector devices = scanDevices(); + for (const auto &device : devices) { + auto temps = readTemperatures(device); + if (!temps.empty()) { + allTemperatures[device] = temps; + } + } + + return allTemperatures; +} diff --git a/src/temperature_chart.cpp b/src/temperature_chart.cpp new file mode 100644 index 0000000..38de490 --- /dev/null +++ b/src/temperature_chart.cpp @@ -0,0 +1,469 @@ +#include "temperature_chart.h" +#include +#include +#include +#include +#include +#include + +TemperatureChart::TemperatureChart(GtkWidget *parent) + : drawingArea(nullptr), tooltipWindow(nullptr), tooltipLabel(nullptr), + maxDataPoints(600), tickHandler(0), + minTemp(MIN_TEMP), maxTemp(MAX_TEMP), minTime(0), maxTime(0), + lastMouseX(-1), lastMouseY(-1) +{ + drawingArea = gtk_drawing_area_new(); + gtk_widget_set_size_request(drawingArea, 800, 400); + gtk_widget_set_can_focus(drawingArea, TRUE); + gtk_widget_set_focusable(drawingArea, TRUE); + + gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(drawingArea), + [](GtkDrawingArea *area, cairo_t *cr, int width, int height, gpointer userData) { + TemperatureChart *self = static_cast(userData); + self->drawChart(area, cr, width, height); + }, this, nullptr); + + // Setup event controller for mouse motion + GtkEventController *motion = gtk_event_controller_motion_new(); + + g_signal_connect_data(motion, "motion", + G_CALLBACK(+[](GtkEventControllerMotion *motion, double x, double y, gpointer userData) -> gboolean { + TemperatureChart *self = static_cast(userData); + self->lastMouseX = x; + self->lastMouseY = y; + + GtkWidget *drawArea = self->drawingArea; + int width = gtk_widget_get_width(drawArea); + int height = gtk_widget_get_height(drawArea); + + NearestPoint nearest = self->findNearestDataPoint(x, y, width, height); + + if (nearest.found && nearest.distance < 5.0) { + self->showTooltip(x, y, nearest); + } else { + self->hideTooltip(); + } + + return FALSE; + }), this, nullptr, (GConnectFlags)0); + + g_signal_connect(motion, "leave", G_CALLBACK(onLeave), this); + gtk_widget_add_controller(drawingArea, motion); + + setupColors(); + + // Setup tick callback for redrawing + tickHandler = g_timeout_add(100, onTick, this); +} + +TemperatureChart::~TemperatureChart() +{ + if (tickHandler) { + g_source_remove(tickHandler); + } + hideTooltip(); +} + +void TemperatureChart::setupColors() +{ + // Define colors for different devices + const GdkRGBA colors[] = { + {1.0, 0.0, 0.0, 1.0}, // Red + {0.0, 0.0, 1.0, 1.0}, // Blue + {0.0, 0.5, 0.0, 1.0}, // Green + {1.0, 0.647, 0.0, 1.0}, // Orange + {0.5, 0.0, 0.5, 1.0}, // Purple + {0.0, 0.5, 0.5, 1.0}, // Teal + {1.0, 0.753, 0.796, 1.0}, // Pink + {0.647, 0.165, 0.165, 1.0} // Brown + }; + + int index = 0; + for (auto &entry : seriesMap) { + entry.second.color = colors[index % 8]; + index++; + } + + updateThemeColors(); +} + +void TemperatureChart::updateThemeColors() +{ + // Detect dark mode by checking the desktop theme + gboolean isDark = FALSE; + + // Try to get the theme from both GTK settings and Gnome settings + GtkSettings *settings = gtk_settings_get_default(); + if (settings) { + gchar *themeName = nullptr; + g_object_get(settings, "gtk-theme-name", &themeName, nullptr); + if (themeName) { + std::string theme(themeName); + // Check if theme name contains "dark" + isDark = (theme.find("dark") != std::string::npos || + theme.find("Dark") != std::string::npos); + g_free(themeName); + } + } + + // Also try GSetting if available + if (!isDark) { + GSettingsSchemaSource *source = g_settings_schema_source_get_default(); + if (source) { + GSettingsSchema *schema = g_settings_schema_source_lookup(source, "org.gnome.desktop.interface", FALSE); + if (schema) { + GSettings *gsettings = g_settings_new("org.gnome.desktop.interface"); + if (gsettings) { + gchar *gtkTheme = g_settings_get_string(gsettings, "gtk-theme"); + if (gtkTheme) { + std::string theme(gtkTheme); + isDark = (theme.find("dark") != std::string::npos || + theme.find("Dark") != std::string::npos); + g_free(gtkTheme); + } + g_object_unref(gsettings); + } + g_settings_schema_unref(schema); + } + } + } + + if (isDark) { + // Dark theme colors + bgColor = {0.1, 0.1, 0.1, 1.0}; // Dark background + textColor = {0.9, 0.9, 0.9, 1.0}; // Light text + gridColor = {0.25, 0.25, 0.25, 1.0}; // Dark gray grid + axisColor = {0.9, 0.9, 0.9, 1.0}; // Light axis + } else { + // Light theme colors + bgColor = {1.0, 1.0, 1.0, 1.0}; // White background + textColor = {0.0, 0.0, 0.0, 1.0}; // Black text + gridColor = {0.9, 0.9, 0.9, 1.0}; // Light gray grid + axisColor = {0.0, 0.0, 0.0, 1.0}; // Black axis + } +} + +GdkRGBA TemperatureChart::getColorForDevice(const std::string &device) +{ + if (colorMap.find(device) == colorMap.end()) { + const GdkRGBA colors[] = { + {1.0, 0.0, 0.0, 1.0}, + {0.0, 0.0, 1.0, 1.0}, + {0.0, 0.5, 0.0, 1.0}, + {1.0, 0.647, 0.0, 1.0}, + {0.5, 0.0, 0.5, 1.0}, + {0.0, 0.5, 0.5, 1.0}, + {1.0, 0.753, 0.796, 1.0}, + {0.647, 0.165, 0.165, 1.0} + }; + + int colorIndex = colorMap.size() % 8; + colorMap[device] = colors[colorIndex]; + } + + return colorMap[device]; +} + +void TemperatureChart::addTemperatureData(const std::string &device, const std::string &sensor, + double temperature, int64_t timestamp) +{ + std::string seriesKey = device + " - " + sensor; + + // Create series if it doesn't exist + if (seriesMap.find(seriesKey) == seriesMap.end()) { + SeriesData series; + series.color = getColorForDevice(device); + series.name = seriesKey; + seriesMap[seriesKey] = series; + } + + // Add data point + DataPoint point; + point.temperature = temperature; + point.timestamp = timestamp; + + seriesMap[seriesKey].points.push_back(point); + + // Keep only last 10 minutes of data + int64_t cutoffTime = timestamp - MAX_TIME_MS; + for (auto &entry : seriesMap) { + auto &points = entry.second.points; + auto it = points.begin(); + while (it != points.end() && it->timestamp < cutoffTime) { + it = points.erase(it); + } + } + + // Update temperature range + minTemp = MIN_TEMP; + maxTemp = MAX_TEMP; + + // Update time range - keep 10 minute window + maxTime = timestamp; + minTime = timestamp - MAX_TIME_MS; + + // Trigger redraw + gtk_widget_queue_draw(drawingArea); +} + +void TemperatureChart::clear() +{ + seriesMap.clear(); + colorMap.clear(); + minTemp = MIN_TEMP; + maxTemp = MAX_TEMP; + minTime = 0; + maxTime = 0; + gtk_widget_queue_draw(drawingArea); +} + +std::vector> TemperatureChart::getDeviceColors() const +{ + std::vector> result; + for (const auto &entry : colorMap) { + result.push_back({entry.first, entry.second}); + } + return result; +} + +gboolean TemperatureChart::onTick(gpointer userData) +{ + TemperatureChart *self = static_cast(userData); + gtk_widget_queue_draw(self->drawingArea); + return TRUE; +} + +void TemperatureChart::drawChart(GtkDrawingArea *area, cairo_t *cr, int width, int height) +{ + // Update theme colors before drawing + updateThemeColors(); + + // Background with theme color + cairo_set_source_rgb(cr, bgColor.red, bgColor.green, bgColor.blue); + cairo_paint(cr); + + if (seriesMap.empty() || maxTime == minTime) { + // Draw placeholder + cairo_set_source_rgb(cr, textColor.red * 0.6, textColor.green * 0.6, textColor.blue * 0.6); + cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); + cairo_set_font_size(cr, 14); + cairo_move_to(cr, width / 2 - 100, height / 2); + cairo_show_text(cr, "Waiting for data..."); + return; + } + + // Margins + double marginLeft = 70, marginRight = 20, marginTop = 40, marginBottom = 40; + double plotWidth = width - marginLeft - marginRight; + double plotHeight = height - marginTop - marginBottom; + + // Draw grid and axes + cairo_set_source_rgb(cr, gridColor.red, gridColor.green, gridColor.blue); + cairo_set_line_width(cr, 0.5); + + // Y axis grid lines (temperature) - every 10 degrees + for (double temp = minTemp; temp <= maxTemp; temp += 10.0) { + double y = marginTop + plotHeight * (1.0 - (temp - minTemp) / (maxTemp - minTemp)); + cairo_move_to(cr, marginLeft, y); + cairo_line_to(cr, marginLeft + plotWidth, y); + cairo_stroke(cr); + } + + // X axis grid lines (time) - 5 lines for 10-minute window + for (int i = 0; i <= 5; i++) { + double x = marginLeft + (plotWidth * i / 5.0); + cairo_move_to(cr, x, marginTop); + cairo_line_to(cr, x, marginTop + plotHeight); + cairo_stroke(cr); + } + + // Draw axes + cairo_set_source_rgb(cr, axisColor.red, axisColor.green, axisColor.blue); + cairo_set_line_width(cr, 2); + cairo_move_to(cr, marginLeft, marginTop); + cairo_line_to(cr, marginLeft, marginTop + plotHeight); + cairo_line_to(cr, marginLeft + plotWidth, marginTop + plotHeight); + cairo_stroke(cr); + + // Draw axis labels + cairo_set_source_rgb(cr, textColor.red, textColor.green, textColor.blue); + cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); + cairo_set_font_size(cr, 10); + + // Temperature labels every 10 degrees on Y axis + for (double temp = minTemp; temp <= maxTemp; temp += 10.0) { + double y = marginTop + plotHeight * (1.0 - (temp - minTemp) / (maxTemp - minTemp)); + + char tempStr[16]; + snprintf(tempStr, sizeof(tempStr), "%.0f°C", temp); + + // Measure text width for proper right alignment + cairo_text_extents_t extents; + cairo_text_extents(cr, tempStr, &extents); + + // Position text right-aligned, 8 pixels before the plot area + double textX = marginLeft - 8 - extents.width; + cairo_move_to(cr, textX, y + 4); + cairo_show_text(cr, tempStr); + } + + // Time labels on X axis (5 points for 10-minute window) + for (int i = 0; i <= 5; i++) { + double x = marginLeft + (plotWidth * i / 5.0); + + // Calculate time offset in seconds + int secondsOffset = (5 - i) * 120; // 10 minutes / 5 = 2 minutes between points + int minutes = secondsOffset / 60; + + char timeStr[16]; + snprintf(timeStr, sizeof(timeStr), "-%d m", minutes); + + cairo_move_to(cr, x - 20, marginTop + plotHeight + 20); + cairo_show_text(cr, timeStr); + } + + // Draw data series (right to left, with 10-minute window) + for (auto &seriesEntry : seriesMap) { + SeriesData &series = seriesEntry.second; + + if (series.points.empty()) continue; + + // Set series color + cairo_set_source_rgba(cr, series.color.red, series.color.green, + series.color.blue, series.color.alpha); + cairo_set_line_width(cr, 2); + + bool firstPoint = true; + for (const DataPoint &point : series.points) { + // Reverse X axis - newer data on the right, older on the left + double x = marginLeft + plotWidth * (1.0 - (double)(maxTime - point.timestamp) / MAX_TIME_MS); + double y = marginTop + plotHeight * (1.0 - (point.temperature - minTemp) / (maxTemp - minTemp)); + + if (firstPoint) { + cairo_move_to(cr, x, y); + firstPoint = false; + } else { + cairo_line_to(cr, x, y); + } + } + cairo_stroke(cr); + + // Draw dots at data points + cairo_set_source_rgba(cr, series.color.red, series.color.green, + series.color.blue, series.color.alpha); + for (const DataPoint &point : series.points) { + double x = marginLeft + plotWidth * (1.0 - (double)(maxTime - point.timestamp) / MAX_TIME_MS); + double y = marginTop + plotHeight * (1.0 - (point.temperature - minTemp) / (maxTemp - minTemp)); + + cairo_arc(cr, x, y, 3, 0, 2 * M_PI); + cairo_fill(cr); + } + } + + // Draw title + cairo_set_source_rgb(cr, textColor.red, textColor.green, textColor.blue); + cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD); + cairo_set_font_size(cr, 16); + cairo_move_to(cr, width / 2 - 150, 25); + cairo_show_text(cr, "NVMe Disk Temperatures (Real-time)"); +} + +TemperatureChart::NearestPoint TemperatureChart::findNearestDataPoint(double mouseX, double mouseY, int width, int height) +{ + NearestPoint result; + result.found = false; + result.distance = std::numeric_limits::max(); + + if (seriesMap.empty() || maxTime == minTime) { + return result; + } + + // Same margins as in drawChart + double marginLeft = 70, marginRight = 20, marginTop = 40, marginBottom = 40; + double plotWidth = width - marginLeft - marginRight; + double plotHeight = height - marginTop - marginBottom; + + // Check if mouse is in plot area + if (mouseX < marginLeft || mouseX > marginLeft + plotWidth || + mouseY < marginTop || mouseY > marginTop + plotHeight) { + return result; + } + for (auto &seriesEntry : seriesMap) { + SeriesData &series = seriesEntry.second; + + for (const DataPoint &point : series.points) { + double x = marginLeft + plotWidth * (1.0 - (double)(maxTime - point.timestamp) / MAX_TIME_MS); + double y = marginTop + plotHeight * (1.0 - (point.temperature - minTemp) / (maxTemp - minTemp)); + + double distance = std::hypot(mouseX - x, mouseY - y); + + if (distance < result.distance) { + result.distance = distance; + result.found = true; + result.temperature = point.temperature; + result.timestamp = point.timestamp; + result.seriesName = seriesEntry.first; + } + } + } + + return result; +} + +void TemperatureChart::showTooltip(double x, double y, const NearestPoint &point) +{ + if (!tooltipWindow) { + tooltipWindow = gtk_popover_new(); + tooltipLabel = gtk_label_new(""); + gtk_widget_set_margin_start(tooltipLabel, 8); + gtk_widget_set_margin_end(tooltipLabel, 8); + gtk_widget_set_margin_top(tooltipLabel, 5); + gtk_widget_set_margin_bottom(tooltipLabel, 5); + gtk_popover_set_child(GTK_POPOVER(tooltipWindow), tooltipLabel); + gtk_popover_set_position(GTK_POPOVER(tooltipWindow), GTK_POS_TOP); + gtk_widget_set_parent(tooltipWindow, drawingArea); + } + + // Format the tooltip text with real current time + char tooltipText[256]; + + // Convert timestamp from milliseconds since epoch to time structure + time_t timeSeconds = point.timestamp / 1000; + struct tm *timeinfo = localtime(&timeSeconds); + + char timeStr[32]; + strftime(timeStr, sizeof(timeStr), "%H:%M:%S", timeinfo); + + snprintf(tooltipText, sizeof(tooltipText), + "%s\n%.1f°C\n%s", + point.seriesName.c_str(), + point.temperature, + timeStr); + + gtk_label_set_text(GTK_LABEL(tooltipLabel), tooltipText); + + // Position the tooltip + GdkRectangle rect; + rect.x = (int)x; + rect.y = (int)y; + rect.width = 1; + rect.height = 1; + gtk_popover_set_pointing_to(GTK_POPOVER(tooltipWindow), &rect); + + gtk_widget_set_visible(tooltipWindow, TRUE); +} + +void TemperatureChart::hideTooltip() +{ + if (tooltipWindow) { + gtk_widget_unparent(tooltipWindow); + tooltipWindow = nullptr; + tooltipLabel = nullptr; + } +} + +void TemperatureChart::onLeave(GtkEventControllerMotion *motion, gpointer userData) +{ + TemperatureChart *self = static_cast(userData); + self->hideTooltip(); +}