Add shared statistics and Prometheus metrics
Publish container image / Build and push (push) Successful in 49s
Publish container image / Deploy to Kubernetes (push) Successful in 31s

This commit is contained in:
2026-08-05 18:32:30 +02:00
parent f5825e806b
commit 2e8a394857
15 changed files with 501 additions and 19 deletions
+10 -2
View File
@@ -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()
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+3
View File
@@ -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;
};
+1
View File
@@ -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
View File
@@ -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;
}
}
+52
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -9,6 +9,7 @@ struct AssetInfo {
QString id;
QString checksum;
QString fileName;
QString type;
QString fileCreatedAt;
QString fileModifiedAt;
bool favorite = false;
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+10
View File
@@ -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; }
}