Unraid Monitor for one-screen overview
This commit is contained in:
commit
e7c3d20c8e
13 changed files with 1545 additions and 0 deletions
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
19
CMakeLists.txt
Normal file
19
CMakeLists.txt
Normal file
|
|
@ -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)
|
||||
504
src/Cards.cpp
Normal file
504
src/Cards.cpp
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
#include "Cards.h"
|
||||
#include "Theme.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QProgressBar>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
// ---------- 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<double>(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<int>(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<QString, const char *> 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<double>(effectiveUsed) / static_cast<double>(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<double>(s.capUsedKb) / static_cast<double>(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<double>(p.fsUsedKb) / static_cast<double>(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<double>(d.fsUsedKb) / static_cast<double>(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);
|
||||
}
|
||||
}
|
||||
87
src/Cards.h
Normal file
87
src/Cards.h
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#pragma once
|
||||
|
||||
#include <QFrame>
|
||||
#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;
|
||||
};
|
||||
199
src/MainWindow.cpp
Normal file
199
src/MainWindow.cpp
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#include "MainWindow.h"
|
||||
|
||||
#include "Cards.h"
|
||||
#include "SettingsDialog.h"
|
||||
#include "Theme.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QScrollArea>
|
||||
#include <QStatusBar>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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<QPair<CardWidget *, bool>> 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();
|
||||
}
|
||||
50
src/MainWindow.h
Normal file
50
src/MainWindow.h
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#pragma once
|
||||
|
||||
#include <QMainWindow>
|
||||
#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;
|
||||
};
|
||||
51
src/Settings.h
Normal file
51
src/Settings.h
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#pragma once
|
||||
|
||||
#include <QSettings>
|
||||
#include <QString>
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
100
src/SettingsDialog.cpp
Normal file
100
src/SettingsDialog.cpp
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#include "SettingsDialog.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QGridLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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;
|
||||
}
|
||||
21
src/SettingsDialog.h
Normal file
21
src/SettingsDialog.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#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;
|
||||
};
|
||||
84
src/Theme.h
Normal file
84
src/Theme.h
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
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
|
||||
255
src/UnraidClient.cpp
Normal file
255
src/UnraidClient.cpp
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
#include "UnraidClient.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSslConfiguration>
|
||||
#include <cmath>
|
||||
|
||||
// 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<qint64>(v.toDouble());
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int toTemp(const QJsonValue &v)
|
||||
{
|
||||
if (!v.isDouble() || std::isnan(v.toDouble()))
|
||||
return -1;
|
||||
return static_cast<int>(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<QSslError> &) { 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://<IP>. 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;
|
||||
}
|
||||
92
src/UnraidClient.h
Normal file
92
src/UnraidClient.h
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
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<DiskInfo> dataDisks;
|
||||
QVector<DiskInfo> parityDisks;
|
||||
QVector<DiskInfo> cachePools;
|
||||
|
||||
QVector<PhysicalDiskInfo> physicalDisks;
|
||||
QVector<ContainerInfo> 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;
|
||||
};
|
||||
42
src/main.cpp
Normal file
42
src/main.cpp
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#include <QApplication>
|
||||
#include <QPalette>
|
||||
#include <QStyleFactory>
|
||||
|
||||
#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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue