diff --git a/src/agent/HttpServer.cpp b/src/agent/HttpServer.cpp index 5dd0366..62c992a 100644 --- a/src/agent/HttpServer.cpp +++ b/src/agent/HttpServer.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -21,7 +22,7 @@ namespace bool isValidDiskName(const std::string& name) { return !name.empty() && name.size() <= 64 && - std::all_of(name.begin(), name.end(), [](char c) { + std::ranges::all_of(name, [](char c) { return std::isalnum(static_cast(c)) || c == '-' || c == '_'; }); } @@ -40,6 +41,7 @@ struct HttpServer::Impl std::string lastRefreshed; std::function refreshCallback; + std::function onClientConnectedCallback; // Refresh cooldown static constexpr int RefreshCooldownSeconds = 600; // 10 minutes @@ -93,7 +95,7 @@ struct HttpServer::Impl }; -HttpServer::HttpServer(const std::string& agentName, unsigned int port) +HttpServer::HttpServer(std::string_view agentName, unsigned int port) : m_impl(std::make_unique()) { m_impl->agentName = agentName; @@ -134,14 +136,12 @@ HttpServer::HttpServer(const std::string& agentName, unsigned int port) std::lock_guard lock(m_impl->dataMutex); - for (const auto& d : m_impl->disks) + auto it = std::ranges::find(m_impl->disks, name, &DiskInfo::GetName); + if (it != m_impl->disks.end()) { - if (d.GetName() == name) - { - nlohmann::json j = d; - res.set_content(j.dump(), "application/json"); - return; - } + nlohmann::json j = *it; + res.set_content(j.dump(), "application/json"); + return; } res.status = 404; res.set_content(R"({"error":"disk not found"})", "application/json"); @@ -190,6 +190,10 @@ HttpServer::HttpServer(const std::string& agentName, unsigned int port) m_impl->sseClients.push_back(client); } + // Notify that a new monitor connected — may trigger conditional refresh + if (m_impl->onClientConnectedCallback) + m_impl->onClientConnectedCallback(); + res.set_header("Cache-Control", "no-cache"); res.set_header("Connection", "keep-alive"); @@ -234,8 +238,14 @@ void HttpServer::setStatusData(GeneralHealth::Health overallHealth, std::vector< const auto now = std::chrono::system_clock::now(); const auto time_t = std::chrono::system_clock::to_time_t(now); + std::tm tm_buf{}; +#ifdef _WIN32 + gmtime_s(&tm_buf, &time_t); +#else + gmtime_r(&time_t, &tm_buf); +#endif std::ostringstream oss; - oss << std::put_time(std::gmtime(&time_t), "%Y-%m-%dT%H:%M:%SZ"); + oss << std::put_time(&tm_buf, "%Y-%m-%dT%H:%M:%SZ"); m_impl->lastRefreshed = oss.str(); } @@ -269,3 +279,9 @@ void HttpServer::setRefreshCallback(std::function cb) { m_impl->refreshCallback = std::move(cb); } + + +void HttpServer::setOnClientConnectedCallback(std::function cb) +{ + m_impl->onClientConnectedCallback = std::move(cb); +} diff --git a/src/agent/HttpServer.h b/src/agent/HttpServer.h index 56dad08..13ad891 100644 --- a/src/agent/HttpServer.h +++ b/src/agent/HttpServer.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include #include @@ -13,7 +13,7 @@ class HttpServer { public: - HttpServer(const std::string& agentName, unsigned int port); + HttpServer(std::string_view agentName, unsigned int port); ~HttpServer(); void setStatusData(GeneralHealth::Health overallHealth, std::vector disks); @@ -25,6 +25,9 @@ class HttpServer // Called to trigger data refresh; the callback does collection and calls setStatusData void setRefreshCallback(std::function cb); + // Called when a new SSE client connects + void setOnClientConnectedCallback(std::function cb); + private: struct Impl; std::unique_ptr m_impl; diff --git a/src/agent/IProbe.h b/src/agent/IProbe.h index 1c25364..ed09a0a 100644 --- a/src/agent/IProbe.h +++ b/src/agent/IProbe.h @@ -1,15 +1,31 @@ #pragma once +#include +#include + #include #include "common/GeneralHealth.h" #include "agent/Disk.h" +struct RefreshPolicy +{ + std::chrono::seconds interval{0}; + bool proactiveCollection = false; + + bool operator==(const RefreshPolicy&) const = default; +}; + + class IProbe { public: virtual ~IProbe() = default; - virtual GeneralHealth::Health GetStatus(const Disk& _disk) = 0; - virtual nlohmann::json GetRawData(const Disk& _disk) = 0; + + [[nodiscard]] virtual RefreshPolicy GetRefreshPolicy() const = 0; + virtual void Refresh(const std::vector& disks) = 0; + + [[nodiscard]] virtual GeneralHealth::Health GetStatus(const Disk& _disk) const = 0; + [[nodiscard]] virtual nlohmann::json GetRawData(const Disk& _disk) const = 0; }; \ No newline at end of file diff --git a/src/agent/SmartHealthAnalyzer.cpp b/src/agent/SmartHealthAnalyzer.cpp index cd355e1..56e6f63 100644 --- a/src/agent/SmartHealthAnalyzer.cpp +++ b/src/agent/SmartHealthAnalyzer.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace { @@ -27,7 +28,7 @@ namespace bool isCriticalAta(uint8_t id, const std::string& name) { - return std::any_of(criticalAttrs.begin(), criticalAttrs.end(), + return std::ranges::any_of(criticalAttrs, [&](const CriticalAttr& ca) { return ca.id == id && name == ca.canonicalName; }); } @@ -59,7 +60,23 @@ SmartHealthAnalyzer::SmartHealthAnalyzer(std::unique_ptr reader) SmartHealthAnalyzer::~SmartHealthAnalyzer() = default; -const IVendorProfile& SmartHealthAnalyzer::profileFor(const std::string& vendor) +RefreshPolicy SmartHealthAnalyzer::GetRefreshPolicy() const +{ + return {std::chrono::hours(4), false}; +} + + +void SmartHealthAnalyzer::Refresh(const std::vector& disks) +{ + for (const auto& disk : disks) + { + m_cachedSmartData[disk.GetDeviceId()] = m_reader->ReadSMARTData(disk); + m_cachedTestStatus[disk.GetDeviceId()] = m_reader->ReadTestStatus(disk); + } +} + + +const IVendorProfile& SmartHealthAnalyzer::profileFor(std::string_view vendor) { static const GenericProfile generic; static const SamsungProfile samsung; @@ -74,9 +91,13 @@ const IVendorProfile& SmartHealthAnalyzer::profileFor(const std::string& vendor) } -GeneralHealth::Health SmartHealthAnalyzer::GetStatus(const Disk& disk) +GeneralHealth::Health SmartHealthAnalyzer::GetStatus(const Disk& disk) const { - const auto smart = m_reader->ReadSMARTData(disk); + auto it = m_cachedSmartData.find(disk.GetDeviceId()); + if (it == m_cachedSmartData.end()) + return GeneralHealth::UNKNOWN; + + const auto& smart = it->second; const auto& profile = profileFor(disk.GetVendor()); auto worst = GeneralHealth::GOOD; @@ -98,18 +119,15 @@ GeneralHealth::Health SmartHealthAnalyzer::GetStatus(const Disk& disk) } // Layer 2b: NVMe critical fields — non-zero raw means trouble - if (isCriticalNvme(attr.name)) - { - if (attr.rawVal > 0) - worst = std::max(worst, GeneralHealth::BAD); - } + if (isCriticalNvme(attr.name) && attr.rawVal > 0) + return GeneralHealth::BAD; // Layer 2c: NVMe wear indicator if (isNvmePercentageUsed(attr.name)) { if (attr.rawVal >= 100) - worst = std::max(worst, GeneralHealth::BAD); - else if (attr.rawVal >= nvmeWearWarningPercent) + return GeneralHealth::BAD; + if (attr.rawVal >= nvmeWearWarningPercent) worst = std::max(worst, GeneralHealth::CHECK_STATUS); } @@ -126,9 +144,13 @@ GeneralHealth::Health SmartHealthAnalyzer::GetStatus(const Disk& disk) } -nlohmann::json SmartHealthAnalyzer::GetRawData(const Disk& disk) +nlohmann::json SmartHealthAnalyzer::GetRawData(const Disk& disk) const { - const auto smart = m_reader->ReadSMARTData(disk); + auto it = m_cachedSmartData.find(disk.GetDeviceId()); + if (it == m_cachedSmartData.end()) + return nlohmann::json{{"type", "smart"}, {"attributes", nlohmann::json::array()}}; + + const auto& smart = it->second; nlohmann::json attrs = nlohmann::json::array(); for (const auto& attr : smart.attributes) @@ -145,7 +167,11 @@ nlohmann::json SmartHealthAnalyzer::GetRawData(const Disk& disk) auto j = nlohmann::json{{"type", "smart"}, {"attributes", attrs}}; - const auto testStatus = m_reader->ReadTestStatus(disk); + auto testIt = m_cachedTestStatus.find(disk.GetDeviceId()); + SmartTestStatus testStatus; + if (testIt != m_cachedTestStatus.end()) + testStatus = testIt->second; + j["selfTestStatus"] = { {"running", testStatus.running}, {"percentRemaining", testStatus.percentRemaining}, diff --git a/src/agent/SmartHealthAnalyzer.h b/src/agent/SmartHealthAnalyzer.h index 10476a2..5419390 100644 --- a/src/agent/SmartHealthAnalyzer.h +++ b/src/agent/SmartHealthAnalyzer.h @@ -3,7 +3,12 @@ #include "IProbe.h" #include "ISmartReader.h" +#include #include +#include +#include + +#include "common/SmartData.h" class IVendorProfile; @@ -13,11 +18,17 @@ class SmartHealthAnalyzer : public IProbe explicit SmartHealthAnalyzer(std::unique_ptr reader); ~SmartHealthAnalyzer() override; - GeneralHealth::Health GetStatus(const Disk& disk) override; - nlohmann::json GetRawData(const Disk& disk) override; + RefreshPolicy GetRefreshPolicy() const override; + void Refresh(const std::vector& disks) override; + + GeneralHealth::Health GetStatus(const Disk& disk) const override; + nlohmann::json GetRawData(const Disk& disk) const override; private: std::unique_ptr m_reader; - static const IVendorProfile& profileFor(const std::string& vendor); + std::map m_cachedSmartData; + std::map m_cachedTestStatus; + + static const IVendorProfile& profileFor(std::string_view vendor); }; diff --git a/src/agent/linux/LinGeneralAnalyzer.cpp b/src/agent/linux/LinGeneralAnalyzer.cpp index 24baf2b..9adcb46 100644 --- a/src/agent/linux/LinGeneralAnalyzer.cpp +++ b/src/agent/linux/LinGeneralAnalyzer.cpp @@ -1,30 +1,56 @@ #include +#include #include #include +#include #include "common/GeneralHealth.h" #include "LinGeneralAnalyzer.h" #include "DmesgParser.h" #include "IPartitionsManager.h" +namespace +{ + std::string getCursorFilePath() + { + if (const char* runDir = std::getenv("XDG_RUNTIME_DIR")) + return std::string(runDir) + "/rdhm-journal-cursor"; + + return "/run/rdhm/journal-cursor"; + } +} + + LinGeneralAnalyzer::LinGeneralAnalyzer(std::shared_ptr manager) : m_partitionsManager(manager) { - refreshState(); + FILE* pipe = popen("which journalctl", "r"); + if (pipe) + { + char buf[256]; + m_useJournalctl = (fgets(buf, sizeof(buf), pipe) != nullptr); + pclose(pipe); + } +} + + +RefreshPolicy LinGeneralAnalyzer::GetRefreshPolicy() const +{ + return {std::chrono::hours(1), true}; } -GeneralHealth::Health LinGeneralAnalyzer::GetStatus(const Disk& disk) +GeneralHealth::Health LinGeneralAnalyzer::GetStatus(const Disk& disk) const { - return m_errors.find(disk) == m_errors.end()? - GeneralHealth::Health::GOOD : - GeneralHealth::Health::BAD; + return m_errors.contains(disk) + ? GeneralHealth::Health::BAD + : GeneralHealth::Health::GOOD; } -nlohmann::json LinGeneralAnalyzer::GetRawData(const Disk& disk) +nlohmann::json LinGeneralAnalyzer::GetRawData(const Disk& disk) const { std::string result; @@ -47,12 +73,20 @@ nlohmann::json LinGeneralAnalyzer::GetRawData(const Disk& disk) } -void LinGeneralAnalyzer::refreshState() +void LinGeneralAnalyzer::Refresh(const std::vector&) { std::string output; std::array buffer; - FILE* pipe = popen("dmesg", "r"); + const auto cursorPath = getCursorFilePath(); + const auto journalCmd = "journalctl -k --cursor-file=" + cursorPath + + " --no-pager -q --output=short"; + + const char* cmd = m_useJournalctl + ? journalCmd.c_str() + : "dmesg"; + + FILE* pipe = popen(cmd, "r"); if (pipe) { while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) @@ -60,5 +94,20 @@ void LinGeneralAnalyzer::refreshState() pclose(pipe); } - m_errors = DmesgParser::parse(output, *m_partitionsManager); + auto newErrors = DmesgParser::parse(output, *m_partitionsManager); + + if (m_useJournalctl) + { + // journalctl with cursor-file returns only new entries since last read. + // Errors are accumulated permanently — once a disk reports an error it stays BAD + // for the lifetime of the agent process. This is intentional: disk I/O errors + // warrant investigation even if they stop recurring. + for (auto& [disk, errors] : newErrors) + m_errors[disk].merge(std::move(errors)); + } + else + { + // dmesg reads the full ring buffer; replace entirely + m_errors = std::move(newErrors); + } } diff --git a/src/agent/linux/LinGeneralAnalyzer.h b/src/agent/linux/LinGeneralAnalyzer.h index 35a8905..da323a6 100644 --- a/src/agent/linux/LinGeneralAnalyzer.h +++ b/src/agent/linux/LinGeneralAnalyzer.h @@ -16,12 +16,14 @@ class LinGeneralAnalyzer : public IProbe public: LinGeneralAnalyzer(std::shared_ptr); - GeneralHealth::Health GetStatus(const Disk& disk) override; - nlohmann::json GetRawData(const Disk& disk) override; + RefreshPolicy GetRefreshPolicy() const override; + void Refresh(const std::vector& disks) override; + + GeneralHealth::Health GetStatus(const Disk& disk) const override; + nlohmann::json GetRawData(const Disk& disk) const override; private: std::map> m_errors; std::shared_ptr m_partitionsManager; - - void refreshState(); + bool m_useJournalctl = false; }; diff --git a/src/agent/main.cpp b/src/agent/main.cpp index 0f3de7d..69d0163 100644 --- a/src/agent/main.cpp +++ b/src/agent/main.cpp @@ -1,11 +1,18 @@ #include +#include +#include +#include #include #include #include +#include +#include #include +#include #include "common/constants.hpp" #include "common/DiskSummary.h" +#include "common/Utils.h" #include "HttpServer.h" #include "MdnsPublisher.h" #include "SystemUtilitiesFactory.h" @@ -14,14 +21,24 @@ namespace { - HttpServer* g_server = nullptr; + std::atomic g_server{nullptr}; + std::atomic g_running{true}; + std::mutex g_bgMutex; + std::condition_variable g_bgCv; void signalHandler(int) { - if (g_server) - g_server->stop(); + g_running = false; + if (auto* srv = g_server.load(std::memory_order_relaxed)) + srv->stop(); } + struct ProbeEntry + { + std::unique_ptr probe; + std::chrono::steady_clock::time_point lastRefresh{}; + }; + DiskSummary buildSummary(const Disk& disk, const std::vector& probeStatuses) { DiskSummary summary; @@ -75,45 +92,83 @@ namespace return summary; } -} + // Returns true if any probe was refreshed + bool refreshStaleProbes(std::vector& entries, + const std::vector& disks, + bool proactiveOnly = false) + { + bool anyRefreshed = false; + const auto now = std::chrono::steady_clock::now(); -void collectAndPublish(HttpServer& server, SystemUtilitiesFactory& factory) -{ - auto diskCollector = factory.diskCollector(); - auto diskCollection = diskCollector->GetDisksList(); - const auto probes = factory.getProbes(); + for (auto& entry : entries) + { + const auto policy = entry.probe->GetRefreshPolicy(); - DiscStatusCalculator calc; - std::vector diskInfos; + if (proactiveOnly && !policy.proactiveCollection) + continue; + + if ((now - entry.lastRefresh) >= policy.interval) + { + entry.probe->Refresh(disks); + entry.lastRefresh = now; + anyRefreshed = true; + } + } - for (const auto& disk : diskCollection) + return anyRefreshed; + } + + void refreshAllProbes(std::span entries, + const std::vector& disks) + { + const auto now = std::chrono::steady_clock::now(); + for (auto& entry : entries) + { + entry.probe->Refresh(disks); + entry.lastRefresh = now; + } + } + + void publishFromCache(HttpServer& server, + const std::span entries, + const std::vector& disks) { - std::vector probeStatuses; - probeStatuses.reserve(probes.size()); + DiscStatusCalculator calc; + std::vector diskInfos; - for (const auto& probe : probes) + for (const auto& disk : disks) { - ProbeStatus status; - status.health = probe->GetStatus(disk); - status.rawData = probe->GetRawData(disk); - probeStatuses.push_back(status); + std::vector probeStatuses; + std::vector healthStatuses; + + for (const auto& entry : entries) + { + probeStatuses.push_back({entry.probe->GetStatus(disk), + entry.probe->GetRawData(disk)}); + healthStatuses.push_back(probeStatuses.back().health); + } + + DiskInfo info; + info.SetName(disk.GetDeviceId()); + info.SetHealth(calc.CalculateCumulativeStatus(healthStatuses)); + info.SetProbesStatuses(probeStatuses); + info.SetSummary(buildSummary(disk, probeStatuses)); + diskInfos.push_back(info); } - DiskInfo info; - info.SetName(disk.GetDeviceId()); - info.SetHealth(calc.CalculateDiskStatus(disk, probes)); - info.SetProbesStatuses(probeStatuses); - info.SetSummary(buildSummary(disk, probeStatuses)); - diskInfos.push_back(info); - } + std::vector statuses; + statuses.reserve(diskInfos.size()); + std::ranges::transform(diskInfos, std::back_inserter(statuses), + &DiskInfo::GetHealth); - std::vector statuses; - std::transform(diskInfos.begin(), diskInfos.end(), std::back_inserter(statuses), - [](const auto& di) { return di.GetHealth(); }); + auto overall = calc.CalculateCumulativeStatus(statuses); + server.setStatusData(overall, std::move(diskInfos)); + } - auto overall = calc.CalculateCumulativeStatus(statuses); - server.setStatusData(overall, std::move(diskInfos)); + // Lock hierarchy (acquire in this order to avoid deadlocks): + // refreshMutex (HttpServer::Impl) → g_probeMutex → dataMutex → sseMutex + std::mutex g_probeMutex; } @@ -144,19 +199,53 @@ int main(int argc, char** argv) auto diskCollector = systemUtilsFactory.diskCollector(); const auto disks = diskCollector->GetDisksList(); - std::cout << "Found disks:\n"; + std::vector> disksInfo{ + {"ID", "type", "capacity", "vendor", "model"} + }; + for (const auto& disk : disks) - std::cout << " " << disk.GetDeviceId() << '\n'; + disksInfo.emplace_back( + std::vector { + disk.GetDeviceId(), + disk.GetDriveType(), + formatBytes(disk.GetCapacity()), + disk.GetVendor(), + disk.GetModel() + }); + + std::cout << "Found disks:\n" << formatTable(disksInfo); + + // Create persistent probes + auto probeUptrs = systemUtilsFactory.getProbes(); + std::vector probeEntries; + std::ranges::transform(probeUptrs, std::back_inserter(probeEntries), [](auto&& probe) { return ProbeEntry(std::move(probe)); }); // Create HTTP server HttpServer server(agentName, RDHMPort); - server.setRefreshCallback([&server, &systemUtilsFactory] { - collectAndPublish(server, systemUtilsFactory); + // POST /api/v1/refresh — force refresh all probes (manual trigger) + server.setRefreshCallback([&server, &probeEntries, &disks] { + std::lock_guard lock(g_probeMutex); + refreshAllProbes(probeEntries, disks); + publishFromCache(server, probeEntries, disks); }); - // Initial data collection - collectAndPublish(server, systemUtilsFactory); + // SSE client connected — immediately send cached state, then wake + // background thread so stale probes get refreshed asynchronously + server.setOnClientConnectedCallback([&server, &probeEntries, &disks] { + { + std::lock_guard lock(g_probeMutex); + publishFromCache(server, probeEntries, disks); + } + g_bgCv.notify_one(); + }); + + // Initial proactive data collection + { + std::lock_guard lock(g_probeMutex); + refreshStaleProbes(probeEntries, disks, true); + publishFromCache(server, probeEntries, disks); + } // Start mDNS publisher MdnsPublisher mdns(agentName, ZeroConfServiceName, RDHMPort); @@ -169,10 +258,36 @@ int main(int argc, char** argv) std::cout << "Agent '" << agentName << "' listening on 0.0.0.0:" << RDHMPort << "\n"; + // Background thread for periodic and on-demand stale-probe refresh + std::thread bgThread([&server, &probeEntries, &disks] { + while (g_running) + { + { + std::unique_lock lock(g_bgMutex); + g_bgCv.wait_for(lock, std::chrono::minutes(1), + [] { return !g_running.load(); }); + } + + if (!g_running) + break; + + std::lock_guard lock(g_probeMutex); + if (refreshStaleProbes(probeEntries, disks)) + { + std::cout << "Publishing new statues\n"; + publishFromCache(server, probeEntries, disks); + } + } + }); + // Blocking — runs the HTTP server server.listen(); // Cleanup + g_running = false; + g_bgCv.notify_all(); + bgThread.join(); + mdns.stop(); g_server = nullptr; diff --git a/src/agent/windows/WinGeneralAnalyzer.cpp b/src/agent/windows/WinGeneralAnalyzer.cpp index 7bb3c32..0cbd58e 100644 --- a/src/agent/windows/WinGeneralAnalyzer.cpp +++ b/src/agent/windows/WinGeneralAnalyzer.cpp @@ -2,13 +2,29 @@ #include "CMDCommunication.h" -GeneralHealth::Health WinGeneralAnalyzer::GetStatus(const Disk& _disk) +RefreshPolicy WinGeneralAnalyzer::GetRefreshPolicy() const +{ + return {std::chrono::hours(1), true}; +} + + +void WinGeneralAnalyzer::Refresh(const std::vector& disks) { CMDCommunication reader; - return reader.CollectDiskStatus(_disk); + for (const auto& disk : disks) + m_cachedStatus[disk.GetDeviceId()] = reader.CollectDiskStatus(disk); +} + + +GeneralHealth::Health WinGeneralAnalyzer::GetStatus(const Disk& _disk) const +{ + auto it = m_cachedStatus.find(_disk.GetDeviceId()); + if (it != m_cachedStatus.end()) + return it->second; + return GeneralHealth::UNKNOWN; } -nlohmann::json WinGeneralAnalyzer::GetRawData(const Disk& _disk) +nlohmann::json WinGeneralAnalyzer::GetRawData(const Disk& _disk) const { return nlohmann::json{{"type", "text"}, {"value", std::string()}}; } diff --git a/src/agent/windows/WinGeneralAnalyzer.h b/src/agent/windows/WinGeneralAnalyzer.h index f310323..8ae1d3f 100644 --- a/src/agent/windows/WinGeneralAnalyzer.h +++ b/src/agent/windows/WinGeneralAnalyzer.h @@ -1,9 +1,19 @@ #pragma once + +#include +#include + #include "../IProbe.h" class WinGeneralAnalyzer : public IProbe { public: - GeneralHealth::Health GetStatus(const Disk& _disk) override; - nlohmann::json GetRawData(const Disk& _disk) override; + RefreshPolicy GetRefreshPolicy() const override; + void Refresh(const std::vector& disks) override; + + GeneralHealth::Health GetStatus(const Disk& _disk) const override; + nlohmann::json GetRawData(const Disk& _disk) const override; + +private: + std::map m_cachedStatus; }; diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index b42fc28..8f546ce 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -10,7 +10,10 @@ add_library(common OBJECT DiskSummary.h DiskInfo.h DiskInfo.cpp - JsonSerialize.h) + JsonSerialize.h + Utils.cpp + Utils.h +) target_include_directories(common PUBLIC diff --git a/src/common/Utils.cpp b/src/common/Utils.cpp new file mode 100644 index 0000000..a432c67 --- /dev/null +++ b/src/common/Utils.cpp @@ -0,0 +1,74 @@ + +#include +#include +#include +#include + +#include "Utils.h" + + +std::string formatBytes(std::uint64_t bytes) +{ + static const std::array units = + { + "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB" + }; + + double size = static_cast(bytes); + std::size_t unitIndex = 0; + + while(size >= 1024.0 && unitIndex < units.size() - 1) + { + size /= 1024.0; + ++unitIndex; + } + + std::ostringstream oss; + + if(unitIndex == 0) + oss << static_cast(size) << " " << units[unitIndex]; + else + oss << std::fixed << std::setprecision(2) << size << " " << units[unitIndex]; + + return oss.str(); +} + + +std::string formatTable(const std::span> rows) +{ + if(rows.empty()) + return {}; + + // liczba kolumn = max długość wiersza + std::size_t cols = 0; + for(const auto& r : rows) + cols = std::max(cols, r.size()); + + // szerokości kolumn + std::vector widths(cols, 0); + for(const auto& r : rows) + { + for(std::size_t c = 0; c < r.size(); ++c) + widths[c] = std::max(widths[c], r[c].size()); + } + + std::ostringstream oss; + + for(const auto& r : rows) + { + for(std::size_t c = 0; c < cols; ++c) + { + const std::string& cell = (c < r.size()) ? r[c] : ""; + + oss << cell; + + // padding (1 spacja odstępu między kolumnami) + std::size_t pad = widths[c] - cell.size(); + for(std::size_t i = 0; i < pad + 1; ++i) + oss << ' '; + } + oss << '\n'; + } + + return oss.str(); +} diff --git a/src/common/Utils.h b/src/common/Utils.h new file mode 100644 index 0000000..73b68c3 --- /dev/null +++ b/src/common/Utils.h @@ -0,0 +1,10 @@ + +#pragma once + +#include +#include +#include + + +std::string formatBytes(std::uint64_t bytes); +std::string formatTable(const std::span> rows); diff --git a/src/monitor/AgentsStatusProvider.cpp b/src/monitor/AgentsStatusProvider.cpp index 7e3bd46..6c91b5d 100644 --- a/src/monitor/AgentsStatusProvider.cpp +++ b/src/monitor/AgentsStatusProvider.cpp @@ -52,8 +52,12 @@ void AgentsStatusProvider::observe(const AgentInformation& info) m_connections.insert(info, std::move(agentConn)); - // Fetch initial status, then trigger refresh, then connect SSE - fetchInitialStatus(info); + emit connectionStateChanged(info, ConnectionState::Connecting); + + // SSE connect triggers agent-side conditional refresh; + // the first SSE event carries initial status data + connectSse(info); + fetchAgentInfo(info); } @@ -70,44 +74,6 @@ void AgentsStatusProvider::unobserve(const AgentInformation& info) } -void AgentsStatusProvider::fetchInitialStatus(const AgentInformation& info) -{ - emit connectionStateChanged(info, ConnectionState::Connecting); - - const QUrl url = QStringLiteral("http://%1:%2/api/v1/refresh") - .arg(formatHost(info.host())) - .arg(info.port()); - - QNetworkRequest req(url); - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - QNetworkReply* reply = m_nam.post(req, QByteArray()); - - QPointer self(this); - QObject::connect(reply, &QNetworkReply::finished, this, [self, info, reply]() { - reply->deleteLater(); - - if (!self) - return; - - if (reply->error() == QNetworkReply::NoError) - { - const QByteArray body = reply->readAll(); - self->parseStatusJson(info, std::string_view{body.constData(), static_cast(body.size())}); - emit self->connectionStateChanged(info, ConnectionState::Connected); - } - else - { - std::cerr << "Failed to fetch status from " << info.name().toStdString() - << ": " << reply->errorString().toStdString() << "\n"; - emit self->connectionStateChanged(info, ConnectionState::Error); - } - - self->connectSse(info); - self->fetchAgentInfo(info); - }); -} - - void AgentsStatusProvider::parseStatusJson(const AgentInformation& info, std::string_view json) { try diff --git a/src/monitor/AgentsStatusProvider.hpp b/src/monitor/AgentsStatusProvider.hpp index bf2f6a0..cab2dfc 100644 --- a/src/monitor/AgentsStatusProvider.hpp +++ b/src/monitor/AgentsStatusProvider.hpp @@ -43,7 +43,6 @@ class AgentsStatusProvider: public IAgentsStatusProvider QHash m_connections; QTimer m_watchdog; - void fetchInitialStatus(const AgentInformation& info); void fetchAgentInfo(const AgentInformation& info); void connectSse(const AgentInformation& info); void handleSseEvent(const AgentInformation& info, const cpp_restapi::SseEvent& event); diff --git a/src/monitor/Qml/AgentDetailPanel.qml b/src/monitor/Qml/AgentDetailPanel.qml index 3a1d40e..30ba399 100644 --- a/src/monitor/Qml/AgentDetailPanel.qml +++ b/src/monitor/Qml/AgentDetailPanel.qml @@ -15,6 +15,7 @@ Item { property var diskData: [] property string lastRefreshed: "" + // TODO: try using funcion from 'common' module function formatBytes(bytes) { if (bytes <= 0) return "" var units = ["B", "KB", "MB", "GB", "TB", "PB"] diff --git a/tests/agent/IProbeMock.h b/tests/agent/IProbeMock.h index 9f2ef7e..7a13e43 100644 --- a/tests/agent/IProbeMock.h +++ b/tests/agent/IProbeMock.h @@ -7,6 +7,8 @@ class IProbeMock : public IProbe { public: - MOCK_METHOD(GeneralHealth::Health, GetStatus, (const Disk&), (override)); - MOCK_METHOD(nlohmann::json, GetRawData, (const Disk&), (override)); + MOCK_METHOD(RefreshPolicy, GetRefreshPolicy, (), (const, override)); + MOCK_METHOD(void, Refresh, (const std::vector&), (override)); + MOCK_METHOD(GeneralHealth::Health, GetStatus, (const Disk&), (const, override)); + MOCK_METHOD(nlohmann::json, GetRawData, (const Disk&), (const, override)); }; \ No newline at end of file diff --git a/tests/agent/SmartHealthAnalyzerTests.cpp b/tests/agent/SmartHealthAnalyzerTests.cpp index 7923979..43bd742 100644 --- a/tests/agent/SmartHealthAnalyzerTests.cpp +++ b/tests/agent/SmartHealthAnalyzerTests.cpp @@ -44,6 +44,8 @@ TEST_F(SmartHealthAnalyzerTest, AllHealthyAttributesReturnGood) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::GOOD, m_analyzer->GetStatus(m_disk)); } @@ -58,6 +60,8 @@ TEST_F(SmartHealthAnalyzerTest, ValueBelowThresholdReturnsBad) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::BAD, m_analyzer->GetStatus(m_disk)); } @@ -71,6 +75,8 @@ TEST_F(SmartHealthAnalyzerTest, ValueEqualToThresholdReturnsBad) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::BAD, m_analyzer->GetStatus(m_disk)); } @@ -84,6 +90,8 @@ TEST_F(SmartHealthAnalyzerTest, CriticalAttributeWithNonZeroRawReturnsCheckStatu }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::CHECK_STATUS, m_analyzer->GetStatus(m_disk)); } @@ -98,6 +106,8 @@ TEST_F(SmartHealthAnalyzerTest, ProximityToThresholdReturnsCheckStatus) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::CHECK_STATUS, m_analyzer->GetStatus(m_disk)); } @@ -111,6 +121,8 @@ TEST_F(SmartHealthAnalyzerTest, ValueWellAboveThresholdIsGood) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::GOOD, m_analyzer->GetStatus(m_disk)); } @@ -129,6 +141,8 @@ TEST_F(SmartHealthAnalyzerTest, SamsungProfileMasksRawReadErrorRate) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({samsungDisk}); EXPECT_EQ(GeneralHealth::GOOD, m_analyzer->GetStatus(samsungDisk)); } @@ -145,6 +159,8 @@ TEST_F(SmartHealthAnalyzerTest, SamsungProfileDetectsRealErrors) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({samsungDisk}); // ID 1 is not in the critical list, so non-zero raw alone doesn't trigger EXPECT_EQ(GeneralHealth::GOOD, m_analyzer->GetStatus(samsungDisk)); @@ -162,6 +178,8 @@ TEST_F(SmartHealthAnalyzerTest, SeagateProfileMasksSeekErrorRate) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::GOOD, m_analyzer->GetStatus(m_disk)); } @@ -179,6 +197,8 @@ TEST_F(SmartHealthAnalyzerTest, UnknownVendorUsesGenericProfile) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::CHECK_STATUS, m_analyzer->GetStatus(m_disk)); } @@ -194,6 +214,7 @@ TEST_F(SmartHealthAnalyzerTest, GetRawDataReturnsSmartJsonFormat) EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); auto json = m_analyzer->GetRawData(m_disk); @@ -220,6 +241,8 @@ TEST_F(SmartHealthAnalyzerTest, EmptySmartDataReturnsGood) SmartData data; // no attributes EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::GOOD, m_analyzer->GetStatus(m_disk)); } @@ -234,6 +257,8 @@ TEST_F(SmartHealthAnalyzerTest, ZeroThresholdSkipsThresholdChecks) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::GOOD, m_analyzer->GetStatus(m_disk)); } @@ -250,6 +275,8 @@ TEST_F(SmartHealthAnalyzerTest, NvmeCriticalWarningNonZeroReturnsBad) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::BAD, m_analyzer->GetStatus(m_disk)); } @@ -264,6 +291,8 @@ TEST_F(SmartHealthAnalyzerTest, NvmeMediaIntegrityErrorsReturnsBad) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::BAD, m_analyzer->GetStatus(m_disk)); } @@ -278,6 +307,8 @@ TEST_F(SmartHealthAnalyzerTest, NvmePercentageUsedHighReturnsCheckStatus) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::CHECK_STATUS, m_analyzer->GetStatus(m_disk)); } @@ -292,6 +323,8 @@ TEST_F(SmartHealthAnalyzerTest, NvmePercentageUsed100ReturnsBad) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::BAD, m_analyzer->GetStatus(m_disk)); } @@ -306,6 +339,8 @@ TEST_F(SmartHealthAnalyzerTest, NvmeAvailableSpareThresholdBreachReturnsBad) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::BAD, m_analyzer->GetStatus(m_disk)); } @@ -324,6 +359,16 @@ TEST_F(SmartHealthAnalyzerTest, NvmeHealthyReturnGood) }; EXPECT_CALL(*m_reader, ReadSMARTData(_)).WillOnce(Return(data)); + EXPECT_CALL(*m_reader, ReadTestStatus(_)).WillOnce(Return(SmartTestStatus{})); + m_analyzer->Refresh({m_disk}); EXPECT_EQ(GeneralHealth::GOOD, m_analyzer->GetStatus(m_disk)); } + + +TEST_F(SmartHealthAnalyzerTest, RefreshPolicyReturnsExpectedValues) +{ + auto policy = m_analyzer->GetRefreshPolicy(); + EXPECT_EQ(std::chrono::hours(4), policy.interval); + EXPECT_FALSE(policy.proactiveCollection); +}