commit e7c3d20c8e647dd6643b182e89a83cf8d420c270 Author: Tronax Date: Sat Jun 13 10:22:24 2026 +0200 Unraid Monitor for one-screen overview diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25407dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Build-Verzeichnisse +build/ +build-*/ +cmake-build-*/ + +# CMake-Artefakte +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +Makefile +*.cmake +!CMakeLists.txt + +# Qt / moc / uic / rcc +moc_*.cpp +moc_*.h +*_autogen/ +qrc_*.cpp +ui_*.h +*.qmake.stash +.qmake.cache + +# Kompilate +*.o +*.obj +*.so +*.a +*.exe +unraid-monitor + +# IDE / Editor +.vscode/ +.idea/ +*.user +*.swp +*~ +.claude/ + +# Betriebssystem +.DS_Store +Thumbs.db diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..a41b362 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.16) +project(unraid-monitor VERSION 1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Widgets Network) + +add_executable(unraid-monitor + src/main.cpp + src/MainWindow.cpp + src/UnraidClient.cpp + src/Cards.cpp + src/SettingsDialog.cpp +) + +target_include_directories(unraid-monitor PRIVATE src) +target_link_libraries(unraid-monitor PRIVATE Qt6::Widgets Qt6::Network) diff --git a/src/Cards.cpp b/src/Cards.cpp new file mode 100644 index 0000000..264c009 --- /dev/null +++ b/src/Cards.cpp @@ -0,0 +1,504 @@ +#include "Cards.h" +#include "Theme.h" + +#include +#include +#include +#include +#include + +// ---------- Hilfsfunktionen ---------- + +namespace { + +QString bytesHuman(double bytes) +{ + static const char *units[] = {"B", "KB", "MB", "GB", "TB", "PB"}; + int i = 0; + while (bytes >= 1024.0 && i < 5) { + bytes /= 1024.0; + ++i; + } + return QString::number(bytes, 'f', bytes >= 100 || i == 0 ? 0 : 1) + " " + units[i]; +} + +QString kbHuman(qint64 kb) +{ + return bytesHuman(static_cast(kb) * 1024.0); +} + +QLabel *makeLabel(const QString &text, const char *color = nullptr, bool bold = false) +{ + auto *l = new QLabel(text); + QString style; + if (color) + style += QString("color:%1;").arg(color); + if (bold) + style += "font-weight:600;"; + if (!style.isEmpty()) + l->setStyleSheet(style); + return l; +} + +QLabel *tempLabel(double t) +{ + if (t < 0) + return makeLabel("–", Theme::MUTED); + const char *c = t < 45 ? Theme::GREEN : (t < 55 ? Theme::YELLOW : Theme::RED); + return makeLabel(QString::number(t, 'f', 0) + " °C", c); +} + +void styleBar(QProgressBar *bar, double pct) +{ + const char *c = pct < 70 ? Theme::GREEN : (pct < 90 ? Theme::YELLOW : Theme::RED); + bar->setStyleSheet(QString("QProgressBar{background:%1;border:none;border-radius:3px;}" + "QProgressBar::chunk{background:%2;border-radius:3px;}") + .arg(Theme::BORDER, c)); + bar->setValue(qBound(0, static_cast(pct + 0.5), 100)); +} + +QProgressBar *makeBar() +{ + auto *bar = new QProgressBar; + bar->setRange(0, 100); + bar->setTextVisible(false); + bar->setFixedHeight(7); + styleBar(bar, 0); + return bar; +} + +// Disk-Status → (Text, Farbe) +QPair diskStatus(const QString &status) +{ + if (status == "DISK_OK") + return {"OK", Theme::GREEN}; + if (status == "DISK_NP" || status == "DISK_NP_DSBL") + return {"N/V", Theme::MUTED}; + if (status.contains("DSBL") || status.contains("INVALID")) + return {"Fehler", Theme::RED}; + if (status.isEmpty()) + return {"–", Theme::MUTED}; + return {status, Theme::YELLOW}; +} + +void clearLayout(QLayout *layout) +{ + while (QLayoutItem *item = layout->takeAt(0)) { + if (QWidget *w = item->widget()) + w->deleteLater(); + if (QLayout *child = item->layout()) + clearLayout(child); + delete item; + } +} + +QGridLayout *makeTableGrid(QVBoxLayout *body) +{ + auto *grid = new QGridLayout; + grid->setHorizontalSpacing(18); + grid->setVerticalSpacing(8); + body->addLayout(grid); + body->addStretch(); + return grid; +} + +void addHeaderRow(QGridLayout *grid, const QStringList &titles, int stretchCol) +{ + for (int i = 0; i < titles.size(); ++i) { + auto *l = new QLabel(titles.at(i)); + l->setObjectName("tableHeader"); + grid->addWidget(l, 0, i); + } + grid->setColumnStretch(stretchCol, 1); +} + +} // namespace + +// ---------- CardWidget ---------- + +CardWidget::CardWidget(const QString &title, bool wide, QWidget *parent) + : QFrame(parent) + , m_wide(wide) +{ + setObjectName("card"); + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(16, 14, 16, 14); + outer->setSpacing(10); + + auto *header = new QHBoxLayout; + auto *titleLabel = new QLabel(title); + titleLabel->setObjectName("cardTitle"); + header->addWidget(titleLabel); + header->addStretch(); + m_headerRight = new QLabel; + m_headerRight->setObjectName("muted"); + header->addWidget(m_headerRight); + outer->addLayout(header); + + m_body = new QVBoxLayout; + m_body->setSpacing(8); + outer->addLayout(m_body); + outer->addStretch(); +} + +// ---------- SystemCard ---------- + +static void addInfoRow(QGridLayout *grid, int row, const QString &key, QLabel *value) +{ + auto *k = new QLabel(key); + k->setObjectName("muted"); + grid->addWidget(k, row, 0); + value->setWordWrap(true); + grid->addWidget(value, row, 1); +} + +SystemCard::SystemCard(QWidget *parent) + : CardWidget(tr("System"), false, parent) +{ + auto *grid = new QGridLayout; + grid->setHorizontalSpacing(16); + grid->setVerticalSpacing(6); + grid->setColumnStretch(1, 1); + + int r = 0; + addInfoRow(grid, r++, tr("Hostname"), m_hostname = new QLabel("–")); + addInfoRow(grid, r++, tr("Unraid"), m_version = new QLabel("–")); + addInfoRow(grid, r++, tr("OS"), m_os = new QLabel("–")); + addInfoRow(grid, r++, tr("Kernel"), m_kernel = new QLabel("–")); + addInfoRow(grid, r++, tr("CPU"), m_cpu = new QLabel("–")); + addInfoRow(grid, r++, tr("Uptime"), m_uptime = new QLabel("–")); + m_body->addLayout(grid); + + auto *cpuRow = new QHBoxLayout; + auto *cpuTitle = new QLabel(tr("CPU-Auslastung")); + cpuTitle->setObjectName("muted"); + cpuRow->addWidget(cpuTitle); + cpuRow->addStretch(); + cpuRow->addWidget(m_cpuPctLabel = new QLabel("–")); + m_body->addLayout(cpuRow); + m_body->addWidget(m_cpuBar = makeBar()); + + auto *memRow = new QHBoxLayout; + auto *memTitle = new QLabel(tr("RAM")); + memTitle->setObjectName("muted"); + memRow->addWidget(memTitle); + memRow->addStretch(); + memRow->addWidget(m_memLabel = new QLabel("–")); + m_body->addLayout(memRow); + m_body->addWidget(m_memBar = makeBar()); +} + +void SystemCard::updateData(const ServerSnapshot &s) +{ + m_hostname->setText(s.hostname.isEmpty() ? "–" : s.hostname); + m_version->setText(s.unraidVersion.isEmpty() + ? "–" + : tr("%1 (API %2)").arg(s.unraidVersion, s.apiVersion)); + m_os->setText(QString("%1 %2").arg(s.distro, s.release).trimmed()); + m_kernel->setText(s.kernel.isEmpty() ? "–" : s.kernel); + m_cpu->setText(s.cpuBrand.isEmpty() + ? "–" + : tr("%1 (%2C/%3T)").arg(s.cpuBrand).arg(s.cpuCores).arg(s.cpuThreads)); + + if (s.bootTime.isValid()) { + qint64 secs = s.bootTime.secsTo(QDateTime::currentDateTime()); + if (secs < 0) + secs = 0; + m_uptime->setText(tr("%1 Tage, %2 Std, %3 Min") + .arg(secs / 86400) + .arg((secs % 86400) / 3600) + .arg((secs % 3600) / 60)); + } else { + m_uptime->setText("–"); + } + + if (s.cpuPercent >= 0) { + m_cpuPctLabel->setText(QString::number(s.cpuPercent, 'f', 1) + " %"); + styleBar(m_cpuBar, s.cpuPercent); + } else { + m_cpuPctLabel->setText("–"); + styleBar(m_cpuBar, 0); + } + + if (s.memTotal > 0) { + // "used" der API enthält Buffer/Cache – tatsächliche Belegung ist total - available + const qint64 effectiveUsed = s.memAvailable > 0 ? s.memTotal - s.memAvailable : s.memUsed; + const double pct = s.memPercent >= 0 + ? s.memPercent + : 100.0 * static_cast(effectiveUsed) / static_cast(s.memTotal); + m_memLabel->setText(tr("%1 / %2 (%3 %)") + .arg(bytesHuman(effectiveUsed), bytesHuman(s.memTotal)) + .arg(pct, 0, 'f', 1)); + styleBar(m_memBar, pct); + } else { + m_memLabel->setText("–"); + styleBar(m_memBar, 0); + } +} + +// ---------- ArrayCard ---------- + +ArrayCard::ArrayCard(QWidget *parent) + : CardWidget(tr("Array"), false, parent) +{ + auto *stateRow = new QHBoxLayout; + auto *stateTitle = new QLabel(tr("Status")); + stateTitle->setObjectName("muted"); + stateRow->addWidget(stateTitle); + stateRow->addStretch(); + stateRow->addWidget(m_state = new QLabel("–")); + m_body->addLayout(stateRow); + + m_body->addSpacing(4); + auto *usageRow = new QHBoxLayout; + auto *usageTitle = new QLabel(tr("Belegung")); + usageTitle->setObjectName("muted"); + usageRow->addWidget(usageTitle); + usageRow->addStretch(); + usageRow->addWidget(m_usageLabel = new QLabel("–")); + m_body->addLayout(usageRow); + m_body->addWidget(m_bar = makeBar()); + + m_freeLabel = new QLabel("–"); + m_freeLabel->setObjectName("muted"); + m_body->addWidget(m_freeLabel); + + m_disksLabel = new QLabel("–"); + m_disksLabel->setObjectName("muted"); + m_body->addWidget(m_disksLabel); +} + +void ArrayCard::updateData(const ServerSnapshot &s) +{ + if (s.arrayState == "STARTED") { + m_state->setText(tr("Gestartet")); + m_state->setStyleSheet(QString("color:%1;font-weight:600;").arg(Theme::GREEN)); + } else if (s.arrayState == "STOPPED") { + m_state->setText(tr("Gestoppt")); + m_state->setStyleSheet(QString("color:%1;font-weight:600;").arg(Theme::RED)); + } else { + m_state->setText(s.arrayState.isEmpty() ? "–" : s.arrayState); + m_state->setStyleSheet(QString("color:%1;font-weight:600;").arg(Theme::YELLOW)); + } + + if (s.capTotalKb > 0) { + const double pct = 100.0 * static_cast(s.capUsedKb) / static_cast(s.capTotalKb); + m_usageLabel->setText(tr("%1 / %2 (%3 %)") + .arg(kbHuman(s.capUsedKb), kbHuman(s.capTotalKb)) + .arg(pct, 0, 'f', 1)); + styleBar(m_bar, pct); + m_freeLabel->setText(tr("%1 frei").arg(kbHuman(s.capFreeKb))); + } else { + m_usageLabel->setText("–"); + m_freeLabel->setText("–"); + styleBar(m_bar, 0); + } + + qint64 errors = 0; + for (const auto &d : s.dataDisks) + errors += d.numErrors; + for (const auto &d : s.parityDisks) + errors += d.numErrors; + m_disksLabel->setText(tr("%1 Daten-Disks, %2 Parität · %3 Fehler") + .arg(s.dataDisks.size()) + .arg(s.parityDisks.size()) + .arg(errors)); +} + +// ---------- PoolsCard ---------- + +PoolsCard::PoolsCard(QWidget *parent) + : CardWidget(tr("Pools / Cache"), false, parent) +{ + m_list = new QVBoxLayout; + m_list->setSpacing(10); + m_body->addLayout(m_list); +} + +void PoolsCard::updateData(const ServerSnapshot &s) +{ + clearLayout(m_list); + + if (s.cachePools.isEmpty()) { + auto *l = new QLabel(tr("Keine Pools vorhanden")); + l->setObjectName("muted"); + m_list->addWidget(l); + return; + } + + for (const DiskInfo &p : s.cachePools) { + auto *row = new QHBoxLayout; + row->addWidget(makeLabel(p.name, nullptr, true)); + row->addStretch(); + row->addWidget(tempLabel(p.temp)); + m_list->addLayout(row); + + auto *bar = makeBar(); + double pct = 0; + QString text = "–"; + if (p.fsSizeKb > 0) { + pct = 100.0 * static_cast(p.fsUsedKb) / static_cast(p.fsSizeKb); + text = tr("%1 / %2 (%3 %)") + .arg(kbHuman(p.fsUsedKb), kbHuman(p.fsSizeKb)) + .arg(pct, 0, 'f', 1); + } + styleBar(bar, pct); + m_list->addWidget(bar); + auto *info = new QLabel(text); + info->setObjectName("muted"); + m_list->addWidget(info); + } +} + +// ---------- DisksCard ---------- + +DisksCard::DisksCard(QWidget *parent) + : CardWidget(tr("Festplatten (Array)"), true, parent) +{ + m_grid = makeTableGrid(m_body); +} + +void DisksCard::updateData(const ServerSnapshot &s) +{ + clearLayout(m_grid); + addHeaderRow(m_grid, + {tr("Disk"), tr("Gerät"), tr("Größe"), tr("Temp"), tr("Status"), + tr("Fehler"), tr("Belegt"), ""}, + 7); + + int row = 1; + auto addSection = [&](const QString &name) { + auto *l = new QLabel(name); + l->setObjectName("sectionLabel"); + m_grid->addWidget(l, row++, 0, 1, 8); + }; + auto addDisk = [&](const DiskInfo &d, bool hasFs) { + m_grid->addWidget(makeLabel(d.name, nullptr, true), row, 0); + m_grid->addWidget(makeLabel(d.device.isEmpty() ? "–" : d.device, Theme::MUTED), row, 1); + m_grid->addWidget(makeLabel(d.sizeKb > 0 ? kbHuman(d.sizeKb) : "–"), row, 2); + if (!d.isSpinning && d.temp < 0) + m_grid->addWidget(makeLabel(tr("Standby"), Theme::MUTED), row, 3); + else + m_grid->addWidget(tempLabel(d.temp), row, 3); + const auto st = diskStatus(d.status); + m_grid->addWidget(makeLabel(st.first, st.second, true), row, 4); + m_grid->addWidget(makeLabel(QString::number(d.numErrors), + d.numErrors > 0 ? Theme::RED : Theme::MUTED), + row, 5); + if (hasFs && d.fsSizeKb > 0) { + const double pct = 100.0 * static_cast(d.fsUsedKb) / static_cast(d.fsSizeKb); + m_grid->addWidget(makeLabel(tr("%1 / %2") + .arg(kbHuman(d.fsUsedKb), kbHuman(d.fsSizeKb))), + row, 6); + auto *bar = makeBar(); + bar->setMinimumWidth(90); + styleBar(bar, pct); + m_grid->addWidget(bar, row, 7); + } else { + m_grid->addWidget(makeLabel("–", Theme::MUTED), row, 6); + } + ++row; + }; + + if (!s.parityDisks.isEmpty()) { + addSection(tr("PARITÄT")); + for (const auto &d : s.parityDisks) + addDisk(d, false); + } + if (!s.dataDisks.isEmpty()) { + addSection(tr("DATEN")); + for (const auto &d : s.dataDisks) + addDisk(d, true); + } + if (s.parityDisks.isEmpty() && s.dataDisks.isEmpty()) { + auto *l = new QLabel(tr("Keine Festplatten gefunden")); + l->setObjectName("muted"); + m_grid->addWidget(l, row, 0, 1, 8); + } +} + +// ---------- SmartCard ---------- + +SmartCard::SmartCard(QWidget *parent) + : CardWidget(tr("SMART / Laufwerke"), true, parent) +{ + m_grid = makeTableGrid(m_body); +} + +void SmartCard::updateData(const ServerSnapshot &s) +{ + clearLayout(m_grid); + addHeaderRow(m_grid, + {tr("Gerät"), tr("Modell"), tr("Seriennummer"), tr("Schnittstelle"), + tr("Größe"), tr("Temp"), tr("SMART")}, + 1); + + int row = 1; + for (const PhysicalDiskInfo &p : s.physicalDisks) { + m_grid->addWidget(makeLabel(p.device, nullptr, true), row, 0); + m_grid->addWidget(makeLabel(p.model.isEmpty() ? "–" : p.model), row, 1); + m_grid->addWidget(makeLabel(p.serial.isEmpty() ? "–" : p.serial, Theme::MUTED), row, 2); + m_grid->addWidget(makeLabel(p.interfaceType.isEmpty() ? "–" : p.interfaceType, + Theme::MUTED), + row, 3); + m_grid->addWidget(makeLabel(p.sizeBytes > 0 ? bytesHuman(p.sizeBytes) : "–"), row, 4); + m_grid->addWidget(tempLabel(p.temperature), row, 5); + if (p.smartStatus == "OK") + m_grid->addWidget(makeLabel(tr("OK"), Theme::GREEN, true), row, 6); + else + m_grid->addWidget(makeLabel(p.smartStatus.isEmpty() ? "–" : p.smartStatus, + Theme::YELLOW, true), + row, 6); + ++row; + } + if (s.physicalDisks.isEmpty()) { + auto *l = new QLabel(tr("Keine Laufwerke gefunden")); + l->setObjectName("muted"); + m_grid->addWidget(l, row, 0, 1, 7); + } +} + +// ---------- DockerCard ---------- + +DockerCard::DockerCard(QWidget *parent) + : CardWidget(tr("Docker-Container"), true, parent) +{ + m_grid = makeTableGrid(m_body); +} + +void DockerCard::updateData(const ServerSnapshot &s) +{ + clearLayout(m_grid); + addHeaderRow(m_grid, + {"", tr("Container"), tr("Image"), tr("Status"), tr("Autostart")}, 2); + + int running = 0; + int row = 1; + for (const ContainerInfo &c : s.containers) { + const char *dotColor = Theme::MUTED; + if (c.state == "RUNNING") { + dotColor = Theme::GREEN; + ++running; + } else if (c.state == "PAUSED") { + dotColor = Theme::YELLOW; + } + auto *dot = makeLabel("●", dotColor); + dot->setToolTip(c.state); + m_grid->addWidget(dot, row, 0); + m_grid->addWidget(makeLabel(c.name, nullptr, true), row, 1); + m_grid->addWidget(makeLabel(c.image, Theme::MUTED), row, 2); + m_grid->addWidget(makeLabel(c.status.isEmpty() ? "–" : c.status), row, 3); + m_grid->addWidget(makeLabel(c.autoStart ? tr("Ja") : tr("Nein"), + c.autoStart ? nullptr : Theme::MUTED), + row, 4); + ++row; + } + + m_headerRight->setText(tr("%1 von %2 laufen").arg(running).arg(s.containers.size())); + + if (s.containers.isEmpty()) { + auto *l = new QLabel(tr("Keine Container gefunden")); + l->setObjectName("muted"); + m_grid->addWidget(l, row, 0, 1, 5); + } +} diff --git a/src/Cards.h b/src/Cards.h new file mode 100644 index 0000000..6b03137 --- /dev/null +++ b/src/Cards.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include "UnraidClient.h" + +class QGridLayout; +class QLabel; +class QProgressBar; +class QVBoxLayout; + +// Basis-Karte: Titel + Inhaltsbereich +class CardWidget : public QFrame { + Q_OBJECT +public: + explicit CardWidget(const QString &title, bool wide, QWidget *parent = nullptr); + bool isWide() const { return m_wide; } + +protected: + QVBoxLayout *m_body; // Inhaltsbereich unter dem Titel + QLabel *m_headerRight; // optionale Zusatzinfo rechts im Titel + +private: + bool m_wide; +}; + +class SystemCard : public CardWidget { + Q_OBJECT +public: + explicit SystemCard(QWidget *parent = nullptr); + void updateData(const ServerSnapshot &s); + +private: + QLabel *m_hostname, *m_version, *m_os, *m_kernel, *m_cpu, *m_uptime; + QLabel *m_cpuPctLabel, *m_memLabel; + QProgressBar *m_cpuBar, *m_memBar; +}; + +class ArrayCard : public CardWidget { + Q_OBJECT +public: + explicit ArrayCard(QWidget *parent = nullptr); + void updateData(const ServerSnapshot &s); + +private: + QLabel *m_state, *m_usageLabel, *m_freeLabel, *m_disksLabel; + QProgressBar *m_bar; +}; + +class PoolsCard : public CardWidget { + Q_OBJECT +public: + explicit PoolsCard(QWidget *parent = nullptr); + void updateData(const ServerSnapshot &s); + +private: + QVBoxLayout *m_list; +}; + +class DisksCard : public CardWidget { + Q_OBJECT +public: + explicit DisksCard(QWidget *parent = nullptr); + void updateData(const ServerSnapshot &s); + +private: + QGridLayout *m_grid; +}; + +class SmartCard : public CardWidget { + Q_OBJECT +public: + explicit SmartCard(QWidget *parent = nullptr); + void updateData(const ServerSnapshot &s); + +private: + QGridLayout *m_grid; +}; + +class DockerCard : public CardWidget { + Q_OBJECT +public: + explicit DockerCard(QWidget *parent = nullptr); + void updateData(const ServerSnapshot &s); + +private: + QGridLayout *m_grid; +}; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp new file mode 100644 index 0000000..a1f3242 --- /dev/null +++ b/src/MainWindow.cpp @@ -0,0 +1,199 @@ +#include "MainWindow.h" + +#include "Cards.h" +#include "SettingsDialog.h" +#include "Theme.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) +{ + setWindowTitle(tr("Unraid Monitor")); + resize(1150, 780); + + auto *central = new QWidget; + auto *outer = new QVBoxLayout(central); + outer->setContentsMargins(18, 14, 18, 0); + outer->setSpacing(12); + + // Kopfzeile + auto *header = new QHBoxLayout; + auto *title = new QLabel(tr("UNRAID MONITOR")); + title->setObjectName("appTitle"); + header->addWidget(title); + + m_hostLabel = new QLabel; + m_hostLabel->setObjectName("muted"); + header->addSpacing(12); + header->addWidget(m_hostLabel); + header->addStretch(); + + m_statusDot = new QLabel("●"); + m_statusDot->setStyleSheet(QString("color:%1;").arg(Theme::MUTED)); + header->addWidget(m_statusDot); + m_statusText = new QLabel(tr("Nicht verbunden")); + m_statusText->setObjectName("muted"); + header->addWidget(m_statusText); + header->addSpacing(12); + + auto *refreshBtn = new QToolButton; + refreshBtn->setText(tr("⟳ Aktualisieren")); + header->addWidget(refreshBtn); + auto *settingsBtn = new QToolButton; + settingsBtn->setText(tr("⚙ Einstellungen")); + header->addWidget(settingsBtn); + outer->addLayout(header); + + // Karten in Scrollbereich + auto *scroll = new QScrollArea; + scroll->setWidgetResizable(true); + auto *scrollContent = new QWidget; + scrollContent->setObjectName("scrollContent"); + auto *scrollLayout = new QVBoxLayout(scrollContent); + scrollLayout->setContentsMargins(0, 0, 6, 14); + m_cardGrid = new QGridLayout; + m_cardGrid->setSpacing(14); + m_cardGrid->setColumnStretch(0, 1); + m_cardGrid->setColumnStretch(1, 1); + scrollLayout->addLayout(m_cardGrid); + scrollLayout->addStretch(); + scroll->setWidget(scrollContent); + outer->addWidget(scroll, 1); + + setCentralWidget(central); + statusBar()->showMessage(tr("Bereit")); + + // Karten + m_systemCard = new SystemCard; + m_arrayCard = new ArrayCard; + m_poolsCard = new PoolsCard; + m_disksCard = new DisksCard; + m_smartCard = new SmartCard; + m_dockerCard = new DockerCard; + + // Client + Timer + m_client = new UnraidClient(this); + connect(m_client, &UnraidClient::snapshotReady, this, &MainWindow::onSnapshot); + connect(m_client, &UnraidClient::requestFailed, this, &MainWindow::onError); + + m_timer = new QTimer(this); + connect(m_timer, &QTimer::timeout, m_client, &UnraidClient::refresh); + + connect(refreshBtn, &QToolButton::clicked, m_client, &UnraidClient::refresh); + connect(settingsBtn, &QToolButton::clicked, this, &MainWindow::openSettings); + + m_settings = AppSettings::load(); + applySettings(); + + if (m_settings.baseUrl.isEmpty() || m_settings.apiKey.isEmpty()) + QTimer::singleShot(0, this, &MainWindow::openSettings); + else + m_client->refresh(); +} + +void MainWindow::applySettings() +{ + m_client->setConnection(m_settings.baseUrl, m_settings.apiKey, m_settings.ignoreSsl); + m_timer->start(m_settings.intervalSec * 1000); + relayoutCards(); +} + +void MainWindow::relayoutCards() +{ + const QList> cards = { + {m_systemCard, m_settings.showSystem}, + {m_arrayCard, m_settings.showArray}, + {m_poolsCard, m_settings.showPools}, + {m_disksCard, m_settings.showDisks}, + {m_smartCard, m_settings.showSmart}, + {m_dockerCard, m_settings.showDocker}, + }; + + // Alle Karten aus dem Grid nehmen (Widgets bleiben erhalten) + for (const auto &c : cards) { + m_cardGrid->removeWidget(c.first); + c.first->setVisible(false); + c.first->setParent(nullptr); + } + + int row = 0, col = 0; + for (const auto &c : cards) { + if (!c.second) + continue; + CardWidget *card = c.first; + if (card->isWide()) { + if (col == 1) { + ++row; + col = 0; + } + m_cardGrid->addWidget(card, row, 0, 1, 2); + ++row; + } else { + m_cardGrid->addWidget(card, row, col); + if (++col == 2) { + col = 0; + ++row; + } + } + card->setVisible(true); + } +} + +void MainWindow::setConnected(bool ok) +{ + m_statusDot->setStyleSheet(QString("color:%1;").arg(ok ? Theme::GREEN : Theme::RED)); + m_statusText->setText(ok ? tr("Verbunden") : tr("Fehler")); +} + +void MainWindow::onSnapshot(const ServerSnapshot &snapshot) +{ + m_hasSnapshot = true; + m_lastSnapshot = snapshot; + setConnected(true); + + m_hostLabel->setText(snapshot.hostname.isEmpty() + ? QString() + : QString("%1 · Unraid %2").arg(snapshot.hostname, + snapshot.unraidVersion)); + + m_systemCard->updateData(snapshot); + m_arrayCard->updateData(snapshot); + m_poolsCard->updateData(snapshot); + m_disksCard->updateData(snapshot); + m_smartCard->updateData(snapshot); + m_dockerCard->updateData(snapshot); + + QString msg = tr("Zuletzt aktualisiert: %1") + .arg(QDateTime::currentDateTime().toString("HH:mm:ss")); + if (!snapshot.partialErrors.isEmpty()) + msg += tr(" · Hinweis: %1").arg(snapshot.partialErrors.join("; ")); + statusBar()->showMessage(msg); +} + +void MainWindow::onError(const QString &error) +{ + setConnected(false); + statusBar()->showMessage(error); +} + +void MainWindow::openSettings() +{ + SettingsDialog dlg(m_settings, this); + if (dlg.exec() != QDialog::Accepted) + return; + m_settings = dlg.result(); + m_settings.save(); + applySettings(); + if (m_hasSnapshot) + onSnapshot(m_lastSnapshot); // sichtbar geschaltete Karten sofort füllen + m_client->refresh(); +} diff --git a/src/MainWindow.h b/src/MainWindow.h new file mode 100644 index 0000000..975820e --- /dev/null +++ b/src/MainWindow.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include "Settings.h" +#include "UnraidClient.h" + +class QGridLayout; +class QLabel; +class QTimer; +class ArrayCard; +class DisksCard; +class DockerCard; +class PoolsCard; +class SmartCard; +class SystemCard; + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + explicit MainWindow(QWidget *parent = nullptr); + +private slots: + void openSettings(); + void onSnapshot(const ServerSnapshot &snapshot); + void onError(const QString &error); + +private: + void applySettings(); + void relayoutCards(); + void setConnected(bool ok); + + AppSettings m_settings; + UnraidClient *m_client; + QTimer *m_timer; + + QGridLayout *m_cardGrid; + QLabel *m_statusDot; + QLabel *m_statusText; + QLabel *m_hostLabel; + + SystemCard *m_systemCard; + ArrayCard *m_arrayCard; + PoolsCard *m_poolsCard; + DisksCard *m_disksCard; + SmartCard *m_smartCard; + DockerCard *m_dockerCard; + + bool m_hasSnapshot = false; + ServerSnapshot m_lastSnapshot; +}; diff --git a/src/Settings.h b/src/Settings.h new file mode 100644 index 0000000..e2f0727 --- /dev/null +++ b/src/Settings.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +struct AppSettings { + QString baseUrl; // z.B. http://192.168.1.10 + QString apiKey; + int intervalSec = 10; + bool ignoreSsl = false; + + // Sichtbarkeit der Module + bool showSystem = true; + bool showArray = true; + bool showPools = true; + bool showDisks = true; + bool showSmart = true; + bool showDocker = true; + + static AppSettings load() + { + QSettings s; + AppSettings a; + a.baseUrl = s.value("server/baseUrl").toString(); + a.apiKey = s.value("server/apiKey").toString(); + a.intervalSec = s.value("server/intervalSec", 10).toInt(); + a.ignoreSsl = s.value("server/ignoreSsl", false).toBool(); + a.showSystem = s.value("cards/system", true).toBool(); + a.showArray = s.value("cards/array", true).toBool(); + a.showPools = s.value("cards/pools", true).toBool(); + a.showDisks = s.value("cards/disks", true).toBool(); + a.showSmart = s.value("cards/smart", true).toBool(); + a.showDocker = s.value("cards/docker", true).toBool(); + return a; + } + + void save() const + { + QSettings s; + s.setValue("server/baseUrl", baseUrl); + s.setValue("server/apiKey", apiKey); + s.setValue("server/intervalSec", intervalSec); + s.setValue("server/ignoreSsl", ignoreSsl); + s.setValue("cards/system", showSystem); + s.setValue("cards/array", showArray); + s.setValue("cards/pools", showPools); + s.setValue("cards/disks", showDisks); + s.setValue("cards/smart", showSmart); + s.setValue("cards/docker", showDocker); + } +}; diff --git a/src/SettingsDialog.cpp b/src/SettingsDialog.cpp new file mode 100644 index 0000000..3be6134 --- /dev/null +++ b/src/SettingsDialog.cpp @@ -0,0 +1,100 @@ +#include "SettingsDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +SettingsDialog::SettingsDialog(const AppSettings &settings, QWidget *parent) + : QDialog(parent) +{ + setWindowTitle(tr("Einstellungen")); + setMinimumWidth(460); + + auto *layout = new QVBoxLayout(this); + layout->setSpacing(14); + + // Verbindung + auto *connBox = new QGroupBox(tr("Verbindung")); + auto *form = new QFormLayout(connBox); + form->setSpacing(10); + + m_url = new QLineEdit(settings.baseUrl); + m_url->setPlaceholderText("http://192.168.1.10"); + form->addRow(tr("Server-URL"), m_url); + + m_apiKey = new QLineEdit(settings.apiKey); + m_apiKey->setEchoMode(QLineEdit::Password); + m_apiKey->setPlaceholderText(tr("Unraid API-Key")); + form->addRow(tr("API-Key"), m_apiKey); + + m_interval = new QSpinBox; + m_interval->setRange(3, 600); + m_interval->setSuffix(tr(" Sekunden")); + m_interval->setValue(settings.intervalSec); + form->addRow(tr("Intervall"), m_interval); + + m_ignoreSsl = new QCheckBox(tr("SSL-Zertifikatsfehler ignorieren (selbstsigniert)")); + m_ignoreSsl->setChecked(settings.ignoreSsl); + form->addRow(QString(), m_ignoreSsl); + + auto *hint = new QLabel(tr("API-Key erstellen: Unraid-WebGUI → Einstellungen → " + "Management Access → API Keys")); + hint->setObjectName("muted"); + hint->setWordWrap(true); + form->addRow(QString(), hint); + layout->addWidget(connBox); + + // Module + auto *modBox = new QGroupBox(tr("Angezeigte Module")); + auto *grid = new QGridLayout(modBox); + grid->setSpacing(10); + m_system = new QCheckBox(tr("System")); + m_array = new QCheckBox(tr("Array")); + m_pools = new QCheckBox(tr("Pools / Cache")); + m_disks = new QCheckBox(tr("Festplatten")); + m_smart = new QCheckBox(tr("SMART / Laufwerke")); + m_docker = new QCheckBox(tr("Docker-Container")); + m_system->setChecked(settings.showSystem); + m_array->setChecked(settings.showArray); + m_pools->setChecked(settings.showPools); + m_disks->setChecked(settings.showDisks); + m_smart->setChecked(settings.showSmart); + m_docker->setChecked(settings.showDocker); + grid->addWidget(m_system, 0, 0); + grid->addWidget(m_array, 0, 1); + grid->addWidget(m_pools, 1, 0); + grid->addWidget(m_disks, 1, 1); + grid->addWidget(m_smart, 2, 0); + grid->addWidget(m_docker, 2, 1); + layout->addWidget(modBox); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttons->button(QDialogButtonBox::Ok)->setText(tr("Speichern")); + buttons->button(QDialogButtonBox::Cancel)->setText(tr("Abbrechen")); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(buttons); +} + +AppSettings SettingsDialog::result() const +{ + AppSettings a; + a.baseUrl = m_url->text().trimmed(); + a.apiKey = m_apiKey->text().trimmed(); + a.intervalSec = m_interval->value(); + a.ignoreSsl = m_ignoreSsl->isChecked(); + a.showSystem = m_system->isChecked(); + a.showArray = m_array->isChecked(); + a.showPools = m_pools->isChecked(); + a.showDisks = m_disks->isChecked(); + a.showSmart = m_smart->isChecked(); + a.showDocker = m_docker->isChecked(); + return a; +} diff --git a/src/SettingsDialog.h b/src/SettingsDialog.h new file mode 100644 index 0000000..c927e29 --- /dev/null +++ b/src/SettingsDialog.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include "Settings.h" + +class QCheckBox; +class QLineEdit; +class QSpinBox; + +class SettingsDialog : public QDialog { + Q_OBJECT +public: + explicit SettingsDialog(const AppSettings &settings, QWidget *parent = nullptr); + AppSettings result() const; + +private: + QLineEdit *m_url, *m_apiKey; + QSpinBox *m_interval; + QCheckBox *m_ignoreSsl; + QCheckBox *m_system, *m_array, *m_pools, *m_disks, *m_smart, *m_docker; +}; diff --git a/src/Theme.h b/src/Theme.h new file mode 100644 index 0000000..cbb3adc --- /dev/null +++ b/src/Theme.h @@ -0,0 +1,84 @@ +#pragma once + +#include + +namespace Theme { + +// Farbpalette +inline const char *BG = "#131519"; +inline const char *CARD = "#1b1e24"; +inline const char *BORDER = "#262b33"; +inline const char *TEXT = "#d6dae2"; +inline const char *MUTED = "#8a93a3"; +inline const char *ACCENT = "#ff8c2f"; // Unraid-Orange +inline const char *GREEN = "#3fb950"; +inline const char *YELLOW = "#d29922"; +inline const char *RED = "#f85149"; + +inline QString styleSheet() +{ + return QStringLiteral(R"( +QMainWindow, QDialog { background-color: %1; } +QWidget { color: %4; font-size: 13px; } + +QScrollArea { border: none; background: %1; } +QWidget#scrollContent { background: %1; } + +QFrame#card { + background-color: %2; + border: 1px solid %3; + border-radius: 10px; +} +QLabel { background: transparent; } +QLabel#cardTitle { font-size: 14px; font-weight: 600; color: #f0f2f5; } +QLabel#appTitle { font-size: 18px; font-weight: 700; color: %6; } +QLabel#muted { color: %5; } +QLabel#sectionLabel { color: %5; font-size: 11px; font-weight: 600; } +QLabel#tableHeader { color: %5; font-size: 11px; font-weight: 600; } + +QToolButton, QPushButton { + background-color: #232830; + border: 1px solid %3; + border-radius: 6px; + padding: 6px 14px; + color: %4; +} +QToolButton:hover, QPushButton:hover { background-color: #2c323c; border-color: %6; } +QToolButton:pressed, QPushButton:pressed { background-color: #1b1e24; } + +QLineEdit, QSpinBox { + background-color: #11141a; + border: 1px solid %3; + border-radius: 6px; + padding: 6px 8px; + color: %4; + selection-background-color: %6; +} +QLineEdit:focus, QSpinBox:focus { border-color: %6; } +QSpinBox::up-button, QSpinBox::down-button { width: 0; } + +QCheckBox { spacing: 8px; } +QCheckBox::indicator { + width: 16px; height: 16px; + border: 1px solid %3; border-radius: 4px; + background: #11141a; +} +QCheckBox::indicator:checked { background-color: %6; border-color: %6; } + +QStatusBar { background: %1; color: %5; border-top: 1px solid %3; } +QStatusBar::item { border: none; } + +QScrollBar:vertical { background: %1; width: 10px; margin: 0; } +QScrollBar::handle:vertical { background: #2c323c; border-radius: 5px; min-height: 30px; } +QScrollBar::handle:vertical:hover { background: #3a4250; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar:horizontal { background: %1; height: 10px; margin: 0; } +QScrollBar::handle:horizontal { background: #2c323c; border-radius: 5px; min-width: 30px; } +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } + +QToolTip { background-color: #232830; color: %4; border: 1px solid %3; } +)") + .arg(BG, CARD, BORDER, TEXT, MUTED, ACCENT); +} + +} // namespace Theme diff --git a/src/UnraidClient.cpp b/src/UnraidClient.cpp new file mode 100644 index 0000000..8420272 --- /dev/null +++ b/src/UnraidClient.cpp @@ -0,0 +1,255 @@ +#include "UnraidClient.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Eine kombinierte Abfrage für alle Dashboard-Daten (Unraid API v4.x / Unraid 7.x) +static const char *kQuery = R"( +query Monitor { + info { + cpu { brand cores threads } + os { distro release kernel hostname uptime } + versions { core { unraid api } } + } + metrics { + cpu { percentTotal } + memory { total used available percentTotal } + } + array { + state + capacity { kilobytes { free used total } } + disks { name device size status temp numErrors fsSize fsFree fsUsed isSpinning } + parities { name device size status temp numErrors isSpinning } + caches { name device size status temp fsSize fsFree fsUsed isSpinning } + } + disks { device name vendor serialNum interfaceType smartStatus temperature size } + docker { containers { names image state status autoStart } } +} +)"; + +// BigInt-Felder kommen je nach Größe als Zahl oder String +static qint64 toLL(const QJsonValue &v) +{ + if (v.isString()) + return v.toString().toLongLong(); + if (v.isDouble()) + return static_cast(v.toDouble()); + return 0; +} + +static int toTemp(const QJsonValue &v) +{ + if (!v.isDouble() || std::isnan(v.toDouble())) + return -1; + return static_cast(v.toDouble()); +} + +UnraidClient::UnraidClient(QObject *parent) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) +{ +} + +void UnraidClient::setConnection(const QString &baseUrl, const QString &apiKey, bool ignoreSsl) +{ + m_baseUrl = baseUrl.trimmed(); + while (m_baseUrl.endsWith('/')) + m_baseUrl.chop(1); + m_apiKey = apiKey.trimmed(); + m_ignoreSsl = ignoreSsl; +} + +void UnraidClient::refresh() +{ + if (m_busy || m_baseUrl.isEmpty()) + return; + m_busy = true; + + QNetworkRequest req(QUrl(m_baseUrl + "/graphql")); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + req.setRawHeader("x-api-key", m_apiKey.toUtf8()); + req.setTransferTimeout(10000); + + if (m_ignoreSsl) { + // Zertifikatsprüfung komplett deaktivieren (selbstsignierte Zertifikate) + QSslConfiguration ssl = QSslConfiguration::defaultConfiguration(); + ssl.setPeerVerifyMode(QSslSocket::VerifyNone); + req.setSslConfiguration(ssl); + } + + const QJsonObject body{{"query", QString::fromLatin1(kQuery)}}; + QNetworkReply *reply = m_nam->post(req, QJsonDocument(body).toJson(QJsonDocument::Compact)); + + if (m_ignoreSsl) { + connect(reply, &QNetworkReply::sslErrors, reply, + [reply](const QList &) { reply->ignoreSslErrors(); }); + } + connect(reply, &QNetworkReply::finished, this, [this, reply] { + m_busy = false; + handleReply(reply); + reply->deleteLater(); + }); +} + +void UnraidClient::handleReply(QNetworkReply *reply) +{ + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (reply->error() != QNetworkReply::NoError && httpStatus == 0) { + // Unraids nginx bricht den TLS-Handshake ab, wenn der Hostname (SNI) nicht + // passt – z.B. bei https://. Das ist kein Zertifikatsfehler und kann + // clientseitig nicht ignoriert werden. + if (reply->errorString().contains("unrecognized name", Qt::CaseInsensitive)) { + emit requestFailed(tr("Server lehnt den Hostnamen ab (SNI). Bei HTTPS den " + "Hostnamen statt der IP verwenden (z.B. https://….myunraid.net " + "oder https://tower.local) – oder http:// nutzen.")); + return; + } + emit requestFailed(tr("Netzwerkfehler: %1").arg(reply->errorString())); + return; + } + if (httpStatus == 401 || httpStatus == 403) { + emit requestFailed(tr("Authentifizierung fehlgeschlagen – API-Key prüfen (HTTP %1)").arg(httpStatus)); + return; + } + + QJsonParseError parseErr; + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &parseErr); + if (parseErr.error != QJsonParseError::NoError || !doc.isObject()) { + emit requestFailed(tr("Ungültige Antwort vom Server (HTTP %1)").arg(httpStatus)); + return; + } + + const QJsonObject root = doc.object(); + QStringList gqlErrors; + for (const QJsonValue &e : root.value("errors").toArray()) + gqlErrors << e.toObject().value("message").toString(); + + const QJsonObject data = root.value("data").toObject(); + if (data.isEmpty()) { + emit requestFailed(gqlErrors.isEmpty() + ? tr("Leere Antwort vom Server (HTTP %1)").arg(httpStatus) + : tr("API-Fehler: %1").arg(gqlErrors.join("; "))); + return; + } + + ServerSnapshot snap = parseData(data); + snap.partialErrors = gqlErrors; + emit snapshotReady(snap); +} + +static DiskInfo parseArrayDisk(const QJsonObject &o) +{ + DiskInfo d; + d.name = o.value("name").toString(); + d.device = o.value("device").toString(); + d.status = o.value("status").toString(); + d.sizeKb = toLL(o.value("size")); + d.fsSizeKb = toLL(o.value("fsSize")); + d.fsFreeKb = toLL(o.value("fsFree")); + d.fsUsedKb = toLL(o.value("fsUsed")); + d.numErrors = toLL(o.value("numErrors")); + d.temp = toTemp(o.value("temp")); + d.isSpinning = o.value("isSpinning").toBool(true); + return d; +} + +ServerSnapshot UnraidClient::parseData(const QJsonObject &data) +{ + ServerSnapshot s; + + // info + const QJsonObject info = data.value("info").toObject(); + const QJsonObject cpu = info.value("cpu").toObject(); + s.cpuBrand = cpu.value("brand").toString(); + s.cpuCores = cpu.value("cores").toInt(); + s.cpuThreads = cpu.value("threads").toInt(); + + const QJsonObject os = info.value("os").toObject(); + s.hostname = os.value("hostname").toString(); + s.distro = os.value("distro").toString(); + s.release = os.value("release").toString(); + s.kernel = os.value("kernel").toString(); + s.bootTime = QDateTime::fromString(os.value("uptime").toString(), Qt::ISODate); + + const QJsonObject core = info.value("versions").toObject().value("core").toObject(); + s.unraidVersion = core.value("unraid").toString(); + s.apiVersion = core.value("api").toString(); + + // metrics + const QJsonObject metrics = data.value("metrics").toObject(); + if (!metrics.isEmpty()) { + const QJsonValue cpuPct = metrics.value("cpu").toObject().value("percentTotal"); + if (cpuPct.isDouble()) + s.cpuPercent = cpuPct.toDouble(); + const QJsonObject mem = metrics.value("memory").toObject(); + s.memTotal = toLL(mem.value("total")); + s.memUsed = toLL(mem.value("used")); + s.memAvailable = toLL(mem.value("available")); + if (mem.value("percentTotal").isDouble()) + s.memPercent = mem.value("percentTotal").toDouble(); + } + + // array + const QJsonObject array = data.value("array").toObject(); + s.arrayState = array.value("state").toString(); + const QJsonObject kb = array.value("capacity").toObject().value("kilobytes").toObject(); + s.capFreeKb = toLL(kb.value("free")); + s.capUsedKb = toLL(kb.value("used")); + s.capTotalKb = toLL(kb.value("total")); + + for (const QJsonValue &v : array.value("disks").toArray()) + s.dataDisks << parseArrayDisk(v.toObject()); + for (const QJsonValue &v : array.value("parities").toArray()) + s.parityDisks << parseArrayDisk(v.toObject()); + for (const QJsonValue &v : array.value("caches").toArray()) + s.cachePools << parseArrayDisk(v.toObject()); + + // physische Disks / SMART + for (const QJsonValue &v : data.value("disks").toArray()) { + const QJsonObject o = v.toObject(); + PhysicalDiskInfo p; + p.device = o.value("device").toString(); + p.model = o.value("name").toString(); + p.vendor = o.value("vendor").toString(); + p.serial = o.value("serialNum").toString(); + p.interfaceType = o.value("interfaceType").toString(); + p.smartStatus = o.value("smartStatus").toString(); + p.sizeBytes = o.value("size").toDouble(); + const QJsonValue t = o.value("temperature"); + p.temperature = (t.isDouble() && !std::isnan(t.toDouble())) ? t.toDouble() : -1; + s.physicalDisks << p; + } + + // Docker + for (const QJsonValue &v : data.value("docker").toObject().value("containers").toArray()) { + const QJsonObject o = v.toObject(); + ContainerInfo c; + const QJsonArray names = o.value("names").toArray(); + if (!names.isEmpty()) { + c.name = names.first().toString(); + if (c.name.startsWith('/')) + c.name.remove(0, 1); + } + c.image = o.value("image").toString(); + c.state = o.value("state").toString(); + c.status = o.value("status").toString(); + c.autoStart = o.value("autoStart").toBool(); + s.containers << c; + } + std::sort(s.containers.begin(), s.containers.end(), + [](const ContainerInfo &a, const ContainerInfo &b) { + const bool ra = a.state == "RUNNING", rb = b.state == "RUNNING"; + if (ra != rb) + return ra; + return a.name.localeAwareCompare(b.name) < 0; + }); + + return s; +} diff --git a/src/UnraidClient.h b/src/UnraidClient.h new file mode 100644 index 0000000..d909089 --- /dev/null +++ b/src/UnraidClient.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +// Array-/Pool-Disk (Werte in KB, wie von der API geliefert) +struct DiskInfo { + QString name; + QString device; + QString status; // DISK_OK, DISK_NP, ... + qint64 sizeKb = 0; + qint64 fsSizeKb = 0; + qint64 fsFreeKb = 0; + qint64 fsUsedKb = 0; + qint64 numErrors = 0; + int temp = -1; // -1 = unbekannt / spun down + bool isSpinning = true; +}; + +// Physische Disk inkl. SMART (Größe in Bytes) +struct PhysicalDiskInfo { + QString device; + QString model; // "name" im Schema + QString vendor; + QString serial; + QString interfaceType; + QString smartStatus; // OK / UNKNOWN + double sizeBytes = 0; + double temperature = -1; // -1 = unbekannt +}; + +struct ContainerInfo { + QString name; + QString image; + QString state; // RUNNING, PAUSED, EXITED + QString status; // "Up 2 days" etc. + bool autoStart = false; +}; + +struct ServerSnapshot { + // System + QString hostname; + QString distro, release, kernel; + QString unraidVersion, apiVersion; + QString cpuBrand; + int cpuCores = 0, cpuThreads = 0; + QDateTime bootTime; + double cpuPercent = -1; + qint64 memTotal = 0, memUsed = 0, memAvailable = 0; // Bytes + double memPercent = -1; + + // Array + QString arrayState; + qint64 capFreeKb = 0, capUsedKb = 0, capTotalKb = 0; + QVector dataDisks; + QVector parityDisks; + QVector cachePools; + + QVector physicalDisks; + QVector containers; + + QStringList partialErrors; // GraphQL-Fehler bei Teilantworten +}; + +class UnraidClient : public QObject { + Q_OBJECT +public: + explicit UnraidClient(QObject *parent = nullptr); + + void setConnection(const QString &baseUrl, const QString &apiKey, bool ignoreSsl); + void refresh(); + +signals: + void snapshotReady(const ServerSnapshot &snapshot); + void requestFailed(const QString &error); + +private: + void handleReply(QNetworkReply *reply); + static ServerSnapshot parseData(const QJsonObject &data); + + QNetworkAccessManager *m_nam; + QString m_baseUrl; + QString m_apiKey; + bool m_ignoreSsl = false; + bool m_busy = false; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..8c8940a --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,42 @@ +#include +#include +#include + +#include "MainWindow.h" +#include "Theme.h" + +static void applyDarkPalette(QApplication &app) +{ + QPalette p; + const QColor bg(Theme::BG), card(Theme::CARD), text(Theme::TEXT), accent(Theme::ACCENT); + p.setColor(QPalette::Window, bg); + p.setColor(QPalette::WindowText, text); + p.setColor(QPalette::Base, QColor("#11141a")); + p.setColor(QPalette::AlternateBase, card); + p.setColor(QPalette::Text, text); + p.setColor(QPalette::Button, card); + p.setColor(QPalette::ButtonText, text); + p.setColor(QPalette::Highlight, accent); + p.setColor(QPalette::HighlightedText, QColor("#11141a")); + p.setColor(QPalette::ToolTipBase, card); + p.setColor(QPalette::ToolTipText, text); + p.setColor(QPalette::PlaceholderText, QColor(Theme::MUTED)); + p.setColor(QPalette::Disabled, QPalette::Text, QColor(Theme::MUTED)); + p.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(Theme::MUTED)); + app.setPalette(p); +} + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QCoreApplication::setOrganizationName("tronax"); + QCoreApplication::setApplicationName("unraid-monitor"); + + app.setStyle(QStyleFactory::create("Fusion")); + applyDarkPalette(app); + app.setStyleSheet(Theme::styleSheet()); + + MainWindow window; + window.show(); + return app.exec(); +}