From 2e8a3948574a759b66d8579c305c3f35906fffd4 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Wed, 5 Aug 2026 18:32:30 +0200 Subject: [PATCH] Add shared statistics and Prometheus metrics --- CMakeLists.txt | 12 ++- README.md | 26 +++++ immich-sync.yaml | 74 +++++++++++-- src/Application.cpp | 13 ++- src/Application.h | 3 + src/ImmichClient.cpp | 1 + src/Metrics.cpp | 249 +++++++++++++++++++++++++++++++++++++++++++ src/Metrics.h | 52 +++++++++ src/SyncSession.cpp | 13 ++- src/SyncSession.h | 4 +- src/Types.h | 1 + tests/TypesTest.cpp | 28 +++++ web/app.js | 19 ++++ web/index.html | 15 ++- web/styles.css | 10 ++ 15 files changed, 501 insertions(+), 19 deletions(-) create mode 100644 src/Metrics.cpp create mode 100644 src/Metrics.h diff --git a/CMakeLists.txt b/CMakeLists.txt index da6e5bf..e07db95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.28) -project(immich-sync VERSION 0.1.2 LANGUAGES CXX) +project(immich-sync VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -15,6 +15,8 @@ qt_add_executable(immich-sync src/Application.h src/ImmichClient.cpp src/ImmichClient.h + src/Metrics.cpp + src/Metrics.h src/SyncSession.cpp src/SyncSession.h src/Types.cpp @@ -56,9 +58,15 @@ if(BUILD_TESTING) tests/TypesTest.cpp src/Types.cpp src/Types.h + src/Metrics.cpp + src/Metrics.h ) target_include_directories(immich-sync-tests PRIVATE src) target_link_libraries(immich-sync-tests PRIVATE Qt6::Core Qt6::Network Qt6::Test) - target_compile_definitions(immich-sync-tests PRIVATE QT_NO_CAST_FROM_ASCII QT_NO_CAST_TO_ASCII) + target_compile_definitions(immich-sync-tests PRIVATE + IMMICH_SYNC_VERSION="${PROJECT_VERSION}" + QT_NO_CAST_FROM_ASCII + QT_NO_CAST_TO_ASCII + ) add_test(NAME immich-sync-types COMMAND immich-sync-tests) endif() diff --git a/README.md b/README.md index 2ba7077..f9c6e0f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ An ephemeral Qt 6 web service that compares two Immich public shares by SHA-1 ch - Each missing original is downloaded into a `QTemporaryFile`, rewound, and used as the seekable `QIODevice` body of the destination multipart request. Only one asset per job is staged, and the file is removed after success, failure, or WebSocket abort. - The browser sends one complete job request immediately after connecting. The service closes the WebSocket when that inspection or synchronization finishes. - Closing the WebSocket early destroys the session and aborts every active network reply. No inspection ID, plan, credentials, or recovery state survives the connection. +- Aggregate counters are the only persistent state. Kubernetes replicas update a small locked JSON file on the shared + CephFS volume; it contains only numeric totals and never contains links, passwords, filenames, or job recovery data. The destination upload deliberately omits Immich's `x-immich-checksum` optimization. That optimization returns duplicates before Immich associates an already-owned asset with the destination shared album. The normal duplicate path both detects the checksum and adds the existing asset to the share. @@ -76,6 +78,30 @@ required. It assumes the `immich-sync.brunner.ninja` hostname shown in the manif public and has no authentication middleware. Because the service can make outbound requests to user-supplied URLs, operators should monitor it for SSRF and bandwidth abuse. +The Deployment runs two replicas with preferred cross-node placement, a zero-unavailable rolling-update strategy, and +a PodDisruptionBudget that keeps at least one replica available. A small `ReadWriteMany` `rook-cephfs` claim stores the +shared lifetime counters. WebSockets need no sticky-session setting: after the HTTP upgrade, the selected pod owns that +connection until the short-lived job finishes or disconnects. + +## Statistics and Prometheus + +The homepage reads `/api/stats` every 30 seconds and displays shared totals for completed synchronization runs, all +successfully synchronized assets, photos, videos, and bytes. Counters begin at zero when statistics are first enabled. + +Each replica exposes Prometheus text metrics at `/metrics`. The manifest includes a `ServiceMonitor` that selects the +`immich-sync` Service and is discovered by the existing Prometheus Operator. Useful Grafana queries include: + +```promql +sum(immich_sync_active_synchronizations) +sum(rate(immich_sync_assets_total{result="success"}[5m])) +sum by (media_type) (rate(immich_sync_asset_bytes_total[5m])) +max(immich_sync_lifetime_synchronizations_total) +max by (media_type) (immich_sync_lifetime_assets_total) +``` + +The lifetime series contains the same shared value on both replicas, so use `max`, not `sum`, for those metrics. +Per-process operational counters and active gauges should be summed across replicas. + ## Continuous delivery The Gitea Actions workflow in `.gitea/workflows/container.yml` builds and pushes diff --git a/immich-sync.yaml b/immich-sync.yaml index 6a2014d..d1327da 100644 --- a/immich-sync.yaml +++ b/immich-sync.yaml @@ -1,15 +1,26 @@ -#apiVersion: v1 -#kind: Namespace -#metadata: -# name: immich-sync -#--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: immich-sync-stats +spec: + accessModes: + - ReadWriteMany + storageClassName: rook-cephfs + resources: + requests: + storage: 64Mi +--- apiVersion: apps/v1 kind: Deployment metadata: name: immich-sync -# namespace: immich-sync spec: - replicas: 1 + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 selector: matchLabels: app: immich-sync @@ -19,6 +30,18 @@ spec: app: immich-sync spec: automountServiceAccountToken: false + securityContext: + fsGroup: 65532 + fsGroupChangePolicy: OnRootMismatch + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: immich-sync + topologyKey: kubernetes.io/hostname containers: - name: immich-sync image: registry.brunner.ninja/feedc0de/immich-sync:latest @@ -26,6 +49,8 @@ spec: env: - name: PORT value: "8090" + - name: STATS_FILE + value: /data/stats.json ports: - name: http containerPort: 8090 @@ -62,16 +87,22 @@ spec: volumeMounts: - name: tmp mountPath: /tmp + - name: stats + mountPath: /data volumes: - name: tmp emptyDir: sizeLimit: 20Gi + - name: stats + persistentVolumeClaim: + claimName: immich-sync-stats --- apiVersion: v1 kind: Service metadata: name: immich-sync -# namespace: immich-sync + labels: + app: immich-sync spec: ports: - name: http @@ -81,11 +112,36 @@ spec: selector: app: immich-sync --- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: immich-sync +spec: + minAvailable: 1 + selector: + matchLabels: + app: immich-sync +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: immich-sync + labels: + release: prometheus +spec: + selector: + matchLabels: + app: immich-sync + endpoints: + - port: http + path: /metrics + interval: 30s + scrapeTimeout: 10s +--- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: immich-sync -# namespace: immich-sync annotations: traefik.ingress.kubernetes.io/router.entrypoints: websecure traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt diff --git a/src/Application.cpp b/src/Application.cpp index 1475163..149fd9b 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -14,7 +14,8 @@ using namespace Qt::StringLiterals; Application::Application(QObject *parent) : - QObject{parent} + QObject{parent}, + m_metrics{qEnvironmentVariable("STATS_FILE")} { configureHttpRoutes(); @@ -52,6 +53,14 @@ void Application::configureHttpRoutes() return QHttpServerResponse{ QJsonObject{{u"status"_s, u"ok"_s}, {u"version"_s, QString::fromLatin1(IMMICH_SYNC_VERSION)}}}; }); + m_httpServer.route(u"/api/stats"_s, [this] { + QJsonObject statistics = m_metrics.statistics(); + statistics.insert(u"version"_s, QString::fromLatin1(IMMICH_SYNC_VERSION)); + return QHttpServerResponse{statistics}; + }); + m_httpServer.route(u"/metrics"_s, [this] { + return QHttpServerResponse{"text/plain; version=0.0.4; charset=utf-8", m_metrics.prometheus()}; + }); m_httpServer.setMissingHandler(this, [](const QHttpServerRequest &, QHttpServerResponder &responder) { responder.write(QByteArrayLiteral("Not found\n"), QByteArrayLiteral("text/plain; charset=utf-8"), QHttpServerResponder::StatusCode::NotFound); @@ -104,7 +113,7 @@ void Application::acceptWebSocket() QWebSocket *socket = pending.release(); socket->setParent(this); socket->setMaxAllowedIncomingMessageSize(64 * 1024); - auto *session = new SyncSession{socket, socket}; + auto *session = new SyncSession{socket, m_metrics, socket}; connect(socket, &QWebSocket::textMessageReceived, session, &SyncSession::handleTextMessage); connect(socket, &QWebSocket::disconnected, session, &SyncSession::abort); connect(socket, &QWebSocket::disconnected, socket, &QObject::deleteLater); diff --git a/src/Application.h b/src/Application.h index b666878..0023b03 100644 --- a/src/Application.h +++ b/src/Application.h @@ -1,5 +1,7 @@ #pragma once +#include "Metrics.h" + #include #include #include @@ -26,6 +28,7 @@ class Application final : public QObject { [[nodiscard]] static QHttpServerResponse resourceResponse(const QString &path, const QByteArray &mimeType); static void applySecurityHeaders(QHttpServerResponse &response); + Metrics m_metrics; QHttpServer m_httpServer; QTcpServer m_tcpServer; }; diff --git a/src/ImmichClient.cpp b/src/ImmichClient.cpp index e6ed506..ac0ee09 100644 --- a/src/ImmichClient.cpp +++ b/src/ImmichClient.cpp @@ -221,6 +221,7 @@ AssetInfo ImmichClient::parseAsset(const QJsonValue &value) .id = asset.value(u"id"_s).toString(), .checksum = asset.value(u"checksum"_s).toString(), .fileName = asset.value(u"originalFileName"_s).toString(), + .type = asset.value(u"type"_s).toString(), .fileCreatedAt = asset.value(u"fileCreatedAt"_s).toString(), .fileModifiedAt = asset.value(u"fileModifiedAt"_s).toString(), .favorite = asset.value(u"isFavorite"_s).toBool(), diff --git a/src/Metrics.cpp b/src/Metrics.cpp new file mode 100644 index 0000000..19766b7 --- /dev/null +++ b/src/Metrics.cpp @@ -0,0 +1,249 @@ +#include "Metrics.h" + +#include +#include +#include +#include +#include + +#include +#include + +using namespace Qt::StringLiterals; + +namespace { +qint64 nonNegativeInteger(const QJsonObject &object, const QString &name) +{ + return qMax(0, object.value(name).toInteger()); +} + +QJsonObject statsJson(const Metrics::LifetimeStats &stats) +{ + return { + {u"syncs"_s, stats.syncs}, {u"assets"_s, stats.assets}, {u"images"_s, stats.images}, + {u"videos"_s, stats.videos}, {u"other"_s, stats.other}, {u"bytes"_s, stats.bytes}, + }; +} + +void appendMetric(QByteArray &output, const QByteArray &name, const QByteArray &labels, qint64 value) +{ + output += name; + if (!labels.isEmpty()) + { + output += '{'; + output += labels; + output += '}'; + } + output += ' '; + output += QByteArray::number(value); + output += '\n'; +} +} + +Metrics::Metrics(QString statsFile) : + m_statsFile{std::move(statsFile)} +{ + m_memoryStats = loadLifetimeStats(); +} + +void Metrics::synchronizationStarted() +{ + ++m_syncsStarted; + ++m_activeSyncs; +} + +void Metrics::synchronizationFinished(bool success) +{ + m_activeSyncs = qMax(0, m_activeSyncs - 1); + if (success) + { + ++m_syncsSucceeded; + } + else + { + ++m_syncsPartial; + } + updateLifetimeStats([](LifetimeStats &stats) { ++stats.syncs; }); +} + +void Metrics::synchronizationAborted() +{ + m_activeSyncs = qMax(0, m_activeSyncs - 1); + ++m_syncsAborted; +} + +void Metrics::assetFinished(const QString &assetType, bool success, qint64 bytes) +{ + const MediaType type = mediaType(assetType); + const auto index = static_cast(type); + if (!success) + { + ++m_assetsFailed[index]; + return; + } + + ++m_assetsSucceeded[index]; + m_assetBytes[index] += qMax(0, bytes); + updateLifetimeStats([type, bytes](LifetimeStats &stats) { + ++stats.assets; + stats.bytes += qMax(0, bytes); + switch (type) + { + case MediaType::Image: + ++stats.images; + break; + case MediaType::Video: + ++stats.videos; + break; + case MediaType::Other: + ++stats.other; + break; + } + }); +} + +QJsonObject Metrics::statistics() const +{ + return statsJson(loadLifetimeStats()); +} + +QByteArray Metrics::prometheus() const +{ + const LifetimeStats lifetime = loadLifetimeStats(); + QByteArray output; + output.reserve(2'048); + output += "# HELP immich_sync_build_info Build and version information.\n"; + output += "# TYPE immich_sync_build_info gauge\n"; + output += "immich_sync_build_info{version=\"" IMMICH_SYNC_VERSION "\"} 1\n"; + output += "# HELP immich_sync_active_synchronizations Synchronizations currently running on this replica.\n"; + output += "# TYPE immich_sync_active_synchronizations gauge\n"; + appendMetric(output, "immich_sync_active_synchronizations", {}, m_activeSyncs); + output += "# HELP immich_sync_synchronizations_started_total Synchronizations started on this process.\n"; + output += "# TYPE immich_sync_synchronizations_started_total counter\n"; + appendMetric(output, "immich_sync_synchronizations_started_total", {}, m_syncsStarted); + output += "# HELP immich_sync_synchronizations_finished_total Synchronization outcomes on this process.\n"; + output += "# TYPE immich_sync_synchronizations_finished_total counter\n"; + appendMetric(output, "immich_sync_synchronizations_finished_total", "result=\"success\"", m_syncsSucceeded); + appendMetric(output, "immich_sync_synchronizations_finished_total", "result=\"partial\"", m_syncsPartial); + appendMetric(output, "immich_sync_synchronizations_finished_total", "result=\"aborted\"", m_syncsAborted); + output += "# HELP immich_sync_assets_total Asset transfer outcomes on this process.\n"; + output += "# TYPE immich_sync_assets_total counter\n"; + static constexpr const char *mediaTypes[]{"image", "video", "other"}; + for (size_t index = 0; index < std::size(mediaTypes); ++index) + { + const QByteArray mediaTypeLabel = QByteArrayLiteral("media_type=\"") + mediaTypes[index] + '"'; + appendMetric(output, "immich_sync_assets_total", mediaTypeLabel + ",result=\"success\"", + m_assetsSucceeded[index]); + appendMetric(output, "immich_sync_assets_total", mediaTypeLabel + ",result=\"failed\"", m_assetsFailed[index]); + } + output += "# HELP immich_sync_asset_bytes_total Successfully transferred original asset bytes on this process.\n"; + output += "# TYPE immich_sync_asset_bytes_total counter\n"; + for (size_t index = 0; index < std::size(mediaTypes); ++index) + { + const QByteArray labels = QByteArrayLiteral("media_type=\"") + mediaTypes[index] + '"'; + appendMetric(output, "immich_sync_asset_bytes_total", labels, m_assetBytes[index]); + } + output += "# HELP immich_sync_lifetime_synchronizations_total Completed synchronizations in persistent shared " + "statistics.\n"; + output += "# TYPE immich_sync_lifetime_synchronizations_total counter\n"; + appendMetric(output, "immich_sync_lifetime_synchronizations_total", {}, lifetime.syncs); + output += + "# HELP immich_sync_lifetime_assets_total Successfully synchronized assets in persistent shared statistics.\n"; + output += "# TYPE immich_sync_lifetime_assets_total counter\n"; + appendMetric(output, "immich_sync_lifetime_assets_total", "media_type=\"image\"", lifetime.images); + appendMetric(output, "immich_sync_lifetime_assets_total", "media_type=\"video\"", lifetime.videos); + appendMetric(output, "immich_sync_lifetime_assets_total", "media_type=\"other\"", lifetime.other); + output += "# HELP immich_sync_lifetime_asset_bytes_total Successfully synchronized original bytes in persistent " + "shared statistics.\n"; + output += "# TYPE immich_sync_lifetime_asset_bytes_total counter\n"; + appendMetric(output, "immich_sync_lifetime_asset_bytes_total", {}, lifetime.bytes); + return output; +} + +Metrics::MediaType Metrics::mediaType(const QString &assetType) +{ + if (assetType.compare(u"IMAGE"_s, Qt::CaseInsensitive) == 0) + { + return MediaType::Image; + } + if (assetType.compare(u"VIDEO"_s, Qt::CaseInsensitive) == 0) + { + return MediaType::Video; + } + return MediaType::Other; +} + +Metrics::LifetimeStats Metrics::loadLifetimeStats() const +{ + if (m_statsFile.isEmpty()) + { + return m_memoryStats; + } + + QFile file{m_statsFile}; + if (!file.exists()) + { + return {}; + } + if (!file.open(QIODevice::ReadOnly)) + { + qWarning() << "Unable to read statistics file" << m_statsFile << file.errorString(); + return m_memoryStats; + } + QJsonParseError error; + const QJsonValue value = QJsonValue::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !value.isObject()) + { + qWarning() << "Unable to parse statistics file" << m_statsFile << error.errorString(); + return m_memoryStats; + } + const QJsonObject object = value.toObject(); + return { + .syncs = nonNegativeInteger(object, u"syncs"_s), + .assets = nonNegativeInteger(object, u"assets"_s), + .images = nonNegativeInteger(object, u"images"_s), + .videos = nonNegativeInteger(object, u"videos"_s), + .other = nonNegativeInteger(object, u"other"_s), + .bytes = nonNegativeInteger(object, u"bytes"_s), + }; +} + +bool Metrics::storeLifetimeStats(const LifetimeStats &stats) const +{ + QSaveFile file{m_statsFile}; + if (!file.open(QIODevice::WriteOnly)) + { + qWarning() << "Unable to write statistics file" << m_statsFile << file.errorString(); + return false; + } + const QByteArray json = QJsonValue{statsJson(stats)}.toJson(QJsonValue::JsonFormat::Compact) + '\n'; + if (file.write(json) != json.size() || !file.commit()) + { + qWarning() << "Unable to commit statistics file" << m_statsFile << file.errorString(); + return false; + } + return true; +} + +void Metrics::updateLifetimeStats(const std::function &update) +{ + if (m_statsFile.isEmpty()) + { + update(m_memoryStats); + return; + } + + QLockFile lock{m_statsFile + u".lock"_s}; + lock.setStaleLockTime(30'000); + if (!lock.tryLock(5'000)) + { + qWarning() << "Unable to lock statistics file" << m_statsFile << lock.error(); + return; + } + LifetimeStats stats = loadLifetimeStats(); + update(stats); + if (storeLifetimeStats(stats)) + { + m_memoryStats = stats; + } +} diff --git a/src/Metrics.h b/src/Metrics.h new file mode 100644 index 0000000..bebeb02 --- /dev/null +++ b/src/Metrics.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +#include + +class Metrics final { + public: + struct LifetimeStats { + qint64 syncs = 0; + qint64 assets = 0; + qint64 images = 0; + qint64 videos = 0; + qint64 other = 0; + qint64 bytes = 0; + }; + + explicit Metrics(QString statsFile = {}); + + void synchronizationStarted(); + void synchronizationFinished(bool success); + void synchronizationAborted(); + void assetFinished(const QString &assetType, bool success, qint64 bytes); + + [[nodiscard]] QJsonObject statistics() const; + [[nodiscard]] QByteArray prometheus() const; + + private: + enum class MediaType { + Image, + Video, + Other, + }; + + [[nodiscard]] static MediaType mediaType(const QString &assetType); + [[nodiscard]] LifetimeStats loadLifetimeStats() const; + bool storeLifetimeStats(const LifetimeStats &stats) const; + void updateLifetimeStats(const std::function &update); + + QString m_statsFile; + LifetimeStats m_memoryStats; + qint64 m_syncsStarted = 0; + qint64 m_syncsSucceeded = 0; + qint64 m_syncsPartial = 0; + qint64 m_syncsAborted = 0; + qint64 m_assetsSucceeded[3]{}; + qint64 m_assetsFailed[3]{}; + qint64 m_assetBytes[3]{}; + qint64 m_activeSyncs = 0; +}; diff --git a/src/SyncSession.cpp b/src/SyncSession.cpp index 976ee18..9dd29f2 100644 --- a/src/SyncSession.cpp +++ b/src/SyncSession.cpp @@ -10,9 +10,10 @@ using namespace Qt::StringLiterals; -SyncSession::SyncSession(QWebSocket *socket, QObject *parent) : +SyncSession::SyncSession(QWebSocket *socket, Metrics &metrics, QObject *parent) : QObject{parent}, - m_socket{socket} + m_socket{socket}, + m_metrics{metrics} { m_requestTimer.setSingleShot(true); m_requestTimer.setInterval(10'000); @@ -315,6 +316,7 @@ void SyncSession::startSync(const QString &direction) m_completed = 0; m_failed = 0; m_active = true; + m_metrics.synchronizationStarted(); qInfo().noquote() << u"Synchronization started: %1, %2 asset(s)"_s.arg(direction).arg(m_total); send({{u"type"_s, u"sync-status"_s}, {u"stage"_s, u"started"_s}, @@ -333,6 +335,7 @@ void SyncSession::startNextTransfer() if (m_queue.isEmpty()) { m_active = false; + m_metrics.synchronizationFinished(m_failed == 0); send({{u"type"_s, u"sync-status"_s}, {u"stage"_s, u"complete"_s}, {u"completed"_s, static_cast(m_completed)}, @@ -346,8 +349,7 @@ void SyncSession::startNextTransfer() const TransferSpec &transfer = m_queue.front(); const quint64 serial = ++m_transferSerial; - qInfo().noquote() << u"Transfer %1/%2 started: %3 (%4)"_s - .arg(m_completed + m_failed + 1) + qInfo().noquote() << u"Transfer %1/%2 started: %3 (%4)"_s.arg(m_completed + m_failed + 1) .arg(m_total) .arg(transfer.asset.fileName, transfer.direction); send({{u"type"_s, u"asset-status"_s}, @@ -490,6 +492,7 @@ void SyncSession::finishTransfer(quint64 serial, bool success, const QString &me } ++m_transferSerial; const TransferSpec transfer = m_queue.takeFirst(); + const qint64 transferredBytes = m_temporaryFile ? m_temporaryFile->size() : 0; qInfo().noquote() << u"Transfer finished: %1 (%2): %3"_s.arg(transfer.asset.fileName, success ? u"success"_s : u"failed"_s, message); if (success) @@ -508,6 +511,7 @@ void SyncSession::finishTransfer(quint64 serial, bool success, const QString &me m_download->abort(); } } + m_metrics.assetFinished(transfer.asset.type, success, transferredBytes); send({{u"type"_s, u"asset-status"_s}, {u"stage"_s, success ? u"complete"_s : u"failed"_s}, {u"direction"_s, transfer.direction}, @@ -587,6 +591,7 @@ void SyncSession::abort() } if (wasActive) { + m_metrics.synchronizationAborted(); qInfo() << "Synchronization aborted because its WebSocket disconnected"; send({{u"type"_s, u"sync-status"_s}, {u"stage"_s, u"aborted"_s}, diff --git a/src/SyncSession.h b/src/SyncSession.h index f7e48cf..d8570fc 100644 --- a/src/SyncSession.h +++ b/src/SyncSession.h @@ -1,6 +1,7 @@ #pragma once #include "ImmichClient.h" +#include "Metrics.h" #include #include @@ -17,7 +18,7 @@ class SyncSession final : public QObject { Q_OBJECT public: - explicit SyncSession(QWebSocket *socket, QObject *parent = nullptr); + explicit SyncSession(QWebSocket *socket, Metrics &metrics, QObject *parent = nullptr); ~SyncSession() override; public slots: @@ -57,6 +58,7 @@ class SyncSession final : public QObject { [[nodiscard]] bool directionAllowed(const QString &direction, QString *reason = nullptr) const; QPointer m_socket; + Metrics &m_metrics; QTimer m_requestTimer; JobKind m_jobKind = JobKind::None; QString m_direction; diff --git a/src/Types.h b/src/Types.h index 94cc08b..285875a 100644 --- a/src/Types.h +++ b/src/Types.h @@ -9,6 +9,7 @@ struct AssetInfo { QString id; QString checksum; QString fileName; + QString type; QString fileCreatedAt; QString fileModifiedAt; bool favorite = false; diff --git a/tests/TypesTest.cpp b/tests/TypesTest.cpp index 3bbe221..377f46c 100644 --- a/tests/TypesTest.cpp +++ b/tests/TypesTest.cpp @@ -1,5 +1,7 @@ #include "Types.h" +#include "Metrics.h" +#include #include using namespace Qt::StringLiterals; @@ -11,6 +13,7 @@ class TypesTest final : public QObject { void parsesShareUrl(); void rejectsNonShareUrl(); void plansByChecksum(); + void persistsStatisticsAcrossInstances(); }; void TypesTest::parsesShareUrl() @@ -46,5 +49,30 @@ void TypesTest::plansByChecksum() QCOMPARE(plan.rightToLeft.front().id, u"5"_s); } +void TypesTest::persistsStatisticsAcrossInstances() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(u"stats.json"_s); + + Metrics first{path}; + first.synchronizationStarted(); + first.assetFinished(u"IMAGE"_s, true, 1'024); + first.synchronizationFinished(true); + + Metrics second{path}; + second.assetFinished(u"VIDEO"_s, true, 2'048); + second.assetFinished(u"IMAGE"_s, false, 512); + + const QJsonObject statistics = first.statistics(); + QCOMPARE(statistics.value(u"syncs"_s).toInteger(), 1); + QCOMPARE(statistics.value(u"assets"_s).toInteger(), 2); + QCOMPARE(statistics.value(u"images"_s).toInteger(), 1); + QCOMPARE(statistics.value(u"videos"_s).toInteger(), 1); + QCOMPARE(statistics.value(u"other"_s).toInteger(), 0); + QCOMPARE(statistics.value(u"bytes"_s).toInteger(), 3'072); + QVERIFY(second.prometheus().contains("immich_sync_lifetime_synchronizations_total 1")); +} + QTEST_MAIN(TypesTest) #include "TypesTest.moc" diff --git a/web/app.js b/web/app.js index 5d50020..39527d1 100644 --- a/web/app.js +++ b/web/app.js @@ -233,6 +233,23 @@ function formatBytes(value) { return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`; } +async function refreshStatistics() { + const status = $('#statisticsStatus'); + try { + const response = await fetch('/api/stats', { cache: 'no-store' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const statistics = await response.json(); + const format = new Intl.NumberFormat(); + $('#statSyncs').textContent = format.format(statistics.syncs || 0); + $('#statAssets').textContent = format.format(statistics.assets || 0); + $('#statImages').textContent = format.format(statistics.images || 0); + $('#statVideos').textContent = format.format(statistics.videos || 0); + status.textContent = `${formatBytes(statistics.bytes || 0)} transferred · since statistics were enabled`; + } catch { + status.textContent = 'Statistics temporarily unavailable'; + } +} + function handle(message) { switch (message.type) { case 'hello': @@ -295,4 +312,6 @@ ui.abort.addEventListener('click', () => { }); setConnection('idle', 'Idle'); +refreshStatistics(); +setInterval(refreshStatistics, 30_000); if (restoreShareFragment()) inspect(); diff --git a/web/index.html b/web/index.html index 8405221..5078ec0 100644 --- a/web/index.html +++ b/web/index.html @@ -28,6 +28,19 @@
Idle
+
+
+

Shared service totals

What we have synchronized

+ Loading… +
+
+
—completed syncs
+
—assets synchronized
+
—photos
+
—videos
+
+
+