Add shared statistics and Prometheus metrics
This commit is contained in:
+10
-2
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+65
-9
@@ -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
|
||||
|
||||
+11
-2
@@ -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);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "Metrics.h"
|
||||
|
||||
#include <QHostAddress>
|
||||
#include <QHttpServer>
|
||||
#include <QObject>
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
#include "Metrics.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QJsonValue>
|
||||
#include <QLockFile>
|
||||
#include <QSaveFile>
|
||||
|
||||
#include <iterator>
|
||||
#include <utility>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
namespace {
|
||||
qint64 nonNegativeInteger(const QJsonObject &object, const QString &name)
|
||||
{
|
||||
return qMax<qint64>(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<qint64>(0, m_activeSyncs - 1);
|
||||
if (success)
|
||||
{
|
||||
++m_syncsSucceeded;
|
||||
}
|
||||
else
|
||||
{
|
||||
++m_syncsPartial;
|
||||
}
|
||||
updateLifetimeStats([](LifetimeStats &stats) { ++stats.syncs; });
|
||||
}
|
||||
|
||||
void Metrics::synchronizationAborted()
|
||||
{
|
||||
m_activeSyncs = qMax<qint64>(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<size_t>(type);
|
||||
if (!success)
|
||||
{
|
||||
++m_assetsFailed[index];
|
||||
return;
|
||||
}
|
||||
|
||||
++m_assetsSucceeded[index];
|
||||
m_assetBytes[index] += qMax<qint64>(0, bytes);
|
||||
updateLifetimeStats([type, bytes](LifetimeStats &stats) {
|
||||
++stats.assets;
|
||||
stats.bytes += qMax<qint64>(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<void(LifetimeStats &)> &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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include <functional>
|
||||
|
||||
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<void(LifetimeStats &)> &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;
|
||||
};
|
||||
+9
-4
@@ -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<qint64>(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},
|
||||
|
||||
+3
-1
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "ImmichClient.h"
|
||||
#include "Metrics.h"
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QJsonObject>
|
||||
@@ -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<QWebSocket> m_socket;
|
||||
Metrics &m_metrics;
|
||||
QTimer m_requestTimer;
|
||||
JobKind m_jobKind = JobKind::None;
|
||||
QString m_direction;
|
||||
|
||||
@@ -9,6 +9,7 @@ struct AssetInfo {
|
||||
QString id;
|
||||
QString checksum;
|
||||
QString fileName;
|
||||
QString type;
|
||||
QString fileCreatedAt;
|
||||
QString fileModifiedAt;
|
||||
bool favorite = false;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "Types.h"
|
||||
#include "Metrics.h"
|
||||
|
||||
#include <QTemporaryDir>
|
||||
#include <QTest>
|
||||
|
||||
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"
|
||||
|
||||
+19
@@ -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();
|
||||
|
||||
+14
-1
@@ -28,6 +28,19 @@
|
||||
<div id="connection" class="connection idle"><i></i><span>Idle</span></div>
|
||||
</header>
|
||||
|
||||
<section class="statistics" aria-labelledby="statisticsTitle">
|
||||
<div class="statistics-heading">
|
||||
<div><p class="eyebrow">Shared service totals</p><h2 id="statisticsTitle">What we have synchronized</h2></div>
|
||||
<span id="statisticsStatus">Loading…</span>
|
||||
</div>
|
||||
<div class="statistics-grid">
|
||||
<div><strong id="statSyncs">—</strong><span>completed syncs</span></div>
|
||||
<div><strong id="statAssets">—</strong><span>assets synchronized</span></div>
|
||||
<div><strong id="statImages">—</strong><span>photos</span></div>
|
||||
<div><strong id="statVideos">—</strong><span>videos</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace" aria-label="Share links">
|
||||
<article id="leftCard" class="share-card left">
|
||||
<div class="card-heading">
|
||||
@@ -87,7 +100,7 @@
|
||||
<footer>
|
||||
<span>Ephemeral by design</span>
|
||||
<span>Checksum comparison</span>
|
||||
<span>Originals streamed in memory</span>
|
||||
<span>One temporary asset at a time</span>
|
||||
<span>No deletion</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -46,6 +46,15 @@ main { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 58px 0 32
|
||||
.connection.connected { color: var(--green); }.connection.connected i { background: var(--green); }
|
||||
.connection.disconnected { color: var(--red); }.connection.disconnected i { background: var(--red); }
|
||||
|
||||
.statistics { margin-bottom: 18px; padding: 20px 22px; border: 1px solid var(--line); border-radius: 18px; background: rgba(18,25,39,.62); }
|
||||
.statistics-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 17px; }
|
||||
.statistics-heading h2 { margin: 5px 0 0; font-size: 1.05rem; }
|
||||
.statistics-heading > span { color: #64748b; font-size: .68rem; }
|
||||
.statistics-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||
.statistics-grid div { display: flex; flex-direction: column; gap: 4px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; background: rgba(0,0,0,.16); }
|
||||
.statistics-grid strong { color: var(--cyan); font-size: 1.55rem; font-variant-numeric: tabular-nums; }
|
||||
.statistics-grid span { color: var(--muted); font-size: .7rem; }
|
||||
|
||||
.workspace { display: grid; grid-template-columns: 1fr 64px 1fr; align-items: stretch; }
|
||||
.share-card, .controls, .activity { border: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(18px); box-shadow: 0 24px 80px rgba(0,0,0,.22); }
|
||||
.share-card { padding: 26px; transition: border-color .2s, transform .2s; }
|
||||
@@ -108,6 +117,7 @@ footer span::before { content: '✓'; color: var(--green); margin-right: 7px; }
|
||||
.hero { grid-template-columns: auto 1fr; }.connection { grid-column: 2; justify-self: start; }
|
||||
.brand-mark { width: 48px; height: 48px; transform: scale(.75); transform-origin: left center; }
|
||||
.workspace { grid-template-columns: 1fr; gap: 10px; }.share-card.left, .share-card.right { border-radius: 17px; }
|
||||
.statistics-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.exchange { flex-direction: row; height: 38px; border: 0; background: transparent; transform: rotate(90deg); }
|
||||
.sync-options { grid-template-columns: 1fr; }.activity-heading { align-items: flex-start; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user