feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration

This commit is contained in:
jubnl
2026-08-20 22:51:49 +02:00
parent 4671720982
commit 6db135da4b
55 changed files with 356 additions and 3 deletions
@@ -39,6 +39,7 @@ export type NotifEventType =
| 'collab_message'
| 'packing_tagged'
| 'version_available'
| 'replica_failure'
| 'synology_session_cleared'
| 'plugin_notification';
@@ -55,12 +56,13 @@ export const ALL_EVENT_TYPES: NotifEventType[] = [
'collab_message',
'packing_tagged',
'version_available',
'replica_failure',
'synology_session_cleared',
'plugin_notification',
];
/** Events that target admins only (shown in admin panel, not in user settings). */
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available']);
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available', 'replica_failure']);
// Compile-time guard: shared NotificationEventKey and server NotifEventType must
// stay in sync. It sits next to the union it guards — it used to live in the
@@ -4,23 +4,36 @@ import { NotificationsMcp } from './notifications.mcp';
import { NotificationsService } from './notifications.service';
import { NotificationPreferencesService } from './notification-preferences.service';
import { ReminderJobsService } from './reminder-jobs.service';
import { StorageHealthNotifierService } from './storage-health-notifier.service';
import { NtfyService } from './transports/ntfy.service';
import { WebhookService } from './transports/webhook.service';
import { MailerModule } from './mailer/mailer.module';
import { AuthModule } from '../auth/auth.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { StorageModule } from '../storage/storage.module';
/** Notifications domain (L6 leaf module). Registered in AppModule.
* AuthModule feeds NotificationsMcp's demo gate; MailerModule carries SMTP,
* which lives outside this module so AuthService can send the password-reset
* mail without AuthModule and NotificationsModule importing each other.
* StorageModule feeds StorageHealthNotifierService, which bridges replica
* failures into admin notifications — this direction has no cycle (Storage
* imports only AppConfig+Audit+Scheduling).
* NotificationsService and NotificationPreferencesService are exported for
* in-container consumers (AdminController's dev test send and preferences tab,
* the plugin RPC surface, HostSurfaceRpc). */
@Module({
imports: [AuthModule, MailerModule, SchedulingModule],
imports: [AuthModule, MailerModule, SchedulingModule, StorageModule],
controllers: [NotificationsController, AdminNotificationPreferencesController],
providers: [NotificationsService, NotificationPreferencesService, WebhookService, NtfyService, NotificationsMcp, ReminderJobsService],
providers: [
NotificationsService,
NotificationPreferencesService,
WebhookService,
NtfyService,
NotificationsMcp,
ReminderJobsService,
StorageHealthNotifierService,
],
exports: [NotificationsService, NotificationPreferencesService],
})
export class NotificationsModule {}
@@ -210,6 +210,13 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view_admin',
navigateTarget: () => '/admin',
},
replica_failure: {
inAppType: 'navigate',
titleKey: 'notif.replica_failure.title',
textKey: 'notif.replica_failure.text',
navigateTextKey: 'notif.action.view',
navigateTarget: () => '/admin?tab=storage',
},
synology_session_cleared: {
inAppType: 'simple',
titleKey: 'notifications.synologySessionCleared.title',
@@ -0,0 +1,28 @@
/**
* Lazy per-backend debounce (backfill/stats/notifications spec): the first
* failure notifies immediately; later failures accumulate and the next one
* past the window rides out as a summary carrying the suppressed count. No
* timers — the summary is sent on the next failure, which is the only moment
* anyone would care. Process-local, like the health ring itself.
*/
export class ReplicaFailureDebouncer {
private readonly windows = new Map<string, { windowStart: number; suppressed: number }>();
constructor(
private readonly windowMs: number,
private readonly now: () => number = Date.now,
) {}
/** null = suppress; otherwise the suppressed-count to report with this send. */
admit(backend: string): number | null {
const now = this.now();
const window = this.windows.get(backend);
if (!window || now - window.windowStart > this.windowMs) {
const suppressed = window?.suppressed ?? 0;
this.windows.set(backend, { windowStart: now, suppressed: 0 });
return suppressed;
}
window.suppressed += 1;
return null;
}
}
@@ -0,0 +1,49 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { StorageEventsService } from '../storage/storage-events.service';
import { NotificationsService } from './notifications.service';
import { ReplicaFailureDebouncer } from './replica-failure-debouncer';
const DEBOUNCE_WINDOW_MS = 60 * 60 * 1000;
/**
* Bridges storage replica failures into the admin notification channels.
* Lives HERE (not in StorageModule) because NotificationsModule → AuthModule
* → StorageModule — the reverse import would cycle. Subscribes at bootstrap;
* the debouncer keeps an outage from becoming a notification storm.
*/
@Injectable()
export class StorageHealthNotifierService implements OnApplicationBootstrap {
private readonly logger = new Logger(StorageHealthNotifierService.name);
private readonly debouncer = new ReplicaFailureDebouncer(DEBOUNCE_WINDOW_MS);
constructor(
private readonly events: StorageEventsService,
private readonly notifications: NotificationsService,
) {}
onApplicationBootstrap(): void {
this.events.onReplicaFailure((failure) => {
const suppressed = this.debouncer.admit(failure.backend);
if (suppressed === null) return;
void this.notifications
.send({
event: 'replica_failure',
actorId: null,
scope: 'admin',
targetId: 0,
params: {
backend: failure.backend,
key: failure.key,
op: failure.op,
error: failure.error,
suppressed: String(suppressed),
},
})
.catch((err: unknown) => {
this.logger.error(
`replica_failure notification failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
});
}
}
@@ -24,6 +24,7 @@ const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4
collab_message: { priority: 3, tags: ['speech_balloon'] },
packing_tagged: { priority: 3, tags: ['luggage'] },
version_available: { priority: 4, tags: ['package'] },
replica_failure: { priority: 5, tags: ['warning', 'floppy_disk'] },
synology_session_cleared: { priority: 3, tags: ['warning'] },
};
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { ReplicaFailureDebouncer } from '../../../../src/nest/notifications/replica-failure-debouncer';
const HOUR = 60 * 60 * 1000;
describe('ReplicaFailureDebouncer', () => {
it('DEB-001 first failure per backend is admitted with suppressed=0', () => {
let now = 1_000_000;
const debouncer = new ReplicaFailureDebouncer(HOUR, () => now);
expect(debouncer.admit('s3-bkp')).toBe(0);
});
it('DEB-002 failures inside the window are suppressed; the next one after it carries the count', () => {
let now = 1_000_000;
const debouncer = new ReplicaFailureDebouncer(HOUR, () => now);
expect(debouncer.admit('s3-bkp')).toBe(0);
now += 1000;
expect(debouncer.admit('s3-bkp')).toBeNull();
expect(debouncer.admit('s3-bkp')).toBeNull();
now += HOUR;
expect(debouncer.admit('s3-bkp')).toBe(2); // the two suppressed ones ride the summary
now += 1000;
expect(debouncer.admit('s3-bkp')).toBeNull(); // window reset
});
it('DEB-003 backends debounce independently', () => {
let now = 1_000_000;
const debouncer = new ReplicaFailureDebouncer(HOUR, () => now);
expect(debouncer.admit('a')).toBe(0);
expect(debouncer.admit('b')).toBe(0);
expect(debouncer.admit('a')).toBeNull();
expect(debouncer.admit('b')).toBeNull();
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from 'vitest';
import { StorageEventsService } from '../../../../src/nest/storage/storage-events.service';
import { StorageHealthNotifierService } from '../../../../src/nest/notifications/storage-health-notifier.service';
import type { NotificationsService } from '../../../../src/nest/notifications/notifications.service';
describe('StorageHealthNotifierService', () => {
it('NOTIF-001 subscribes on bootstrap and sends an admin-scoped replica_failure with params', () => {
const events = new StorageEventsService();
const send = vi.fn().mockResolvedValue(undefined);
const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService);
notifier.onApplicationBootstrap();
events.emitReplicaFailure({ backend: 's3-bkp', key: 'backups/db.zip', op: 'put', error: 'timeout', at: 1 });
expect(send).toHaveBeenCalledWith({
event: 'replica_failure',
actorId: null,
scope: 'admin',
targetId: 0,
params: { backend: 's3-bkp', key: 'backups/db.zip', op: 'put', error: 'timeout', suppressed: '0' },
});
// Second failure inside the window: suppressed, no second send.
events.emitReplicaFailure({ backend: 's3-bkp', key: 'backups/x.zip', op: 'put', error: 'timeout', at: 2 });
expect(send).toHaveBeenCalledTimes(1);
});
it('NOTIF-002 a rejected send never throws into the emitter (write path stays safe)', () => {
const events = new StorageEventsService();
const send = vi.fn().mockRejectedValue(new Error('smtp down'));
const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService);
notifier.onApplicationBootstrap();
expect(() =>
events.emitReplicaFailure({ backend: 'b', key: 'k', op: 'delete', error: 'e', at: 3 }),
).not.toThrow();
});
});
@@ -52,6 +52,12 @@ const ar: NotificationLocale = {
title: 'إصدار TREK جديد متاح',
body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'تمت إعادة تعيين جلسة Synology',
body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} عيّنك في {category} في {trip}',
'notif.version_available.title': 'إصدار جديد متاح',
'notif.version_available.text': 'TREK {version} متاح الآن',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'عرض الرحلة',
'notif.action.view_collab': 'عرض الرسائل',
'notif.action.view_packing': 'عرض التعبئة',
@@ -52,6 +52,12 @@ const br: NotificationLocale = {
title: 'Nova versão do TREK disponível',
body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Sessão Synology encerrada',
body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} atribuiu você a {category} em {trip}',
'notif.version_available.title': 'Nova versão disponível',
'notif.version_available.text': 'TREK {version} está disponível',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Ver viagem',
'notif.action.view_collab': 'Ver mensagens',
'notif.action.view_packing': 'Ver bagagem',
@@ -48,6 +48,12 @@ const ca: NotificationLocale = {
title: 'Versió nova de TREK disponible',
body: `TREK ${p.version} ja està disponible. Visita el panell d'administració per actualitzar.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Sessió de Synology tancada',
body: 'El teu compte o URL de Synology ha canviat. Has tancat la sessió a Synology Photos.',
+2
View File
@@ -25,6 +25,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} et va assignar a {category} a {trip}',
'notif.version_available.title': 'Versió nova disponible',
'notif.version_available.text': 'TREK {version} ja està disponible',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Mostra el viatge',
'notif.action.view_collab': 'Mostra els missatges',
'notif.action.view_packing': "Mostra l'equipatge",
@@ -52,6 +52,12 @@ const cs: NotificationLocale = {
title: 'Nová verze TREK dostupná',
body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Relace Synology byla zrušena',
body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} vás přiřadil k {category} v {trip}',
'notif.version_available.title': 'Nová verze dostupná',
'notif.version_available.text': 'TREK {version} je nyní dostupný',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Zobrazit výlet',
'notif.action.view_collab': 'Zobrazit zprávy',
'notif.action.view_packing': 'Zobrazit balení',
@@ -52,6 +52,12 @@ const de: NotificationLocale = {
title: 'Neue TREK-Version verfügbar',
body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology-Sitzung beendet',
body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} hat dich zu {category} in {trip} zugewiesen',
'notif.version_available.title': 'Neue Version verfügbar',
'notif.version_available.text': 'TREK {version} ist jetzt verfügbar',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Reise ansehen',
'notif.action.view_collab': 'Nachrichten ansehen',
'notif.action.view_packing': 'Packliste ansehen',
@@ -52,6 +52,12 @@ const en: NotificationLocale = {
title: 'New TREK version available',
body: `TREK ${p.version} is now available. Visit the admin panel to update.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology session cleared',
body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} assigned you to {category} in {trip}',
'notif.version_available.title': 'New Version Available',
'notif.version_available.text': 'TREK {version} is now available',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'View Trip',
'notif.action.view_collab': 'View Messages',
'notif.action.view_packing': 'View Packing',
@@ -52,6 +52,12 @@ const es: NotificationLocale = {
title: 'Nueva versión de TREK disponible',
body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Sesión de Synology cerrada',
body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} te asignó a {category} en {trip}',
'notif.version_available.title': 'Nueva versión disponible',
'notif.version_available.text': 'TREK {version} ya está disponible',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Ver viaje',
'notif.action.view_collab': 'Ver mensajes',
'notif.action.view_packing': 'Ver equipaje',
@@ -33,6 +33,7 @@ export type NotificationEventKey =
| 'collab_message'
| 'packing_tagged'
| 'version_available'
| 'replica_failure'
| 'synology_session_cleared'
| 'plugin_notification';
@@ -52,6 +52,12 @@ const fr: NotificationLocale = {
title: 'Nouvelle version TREK disponible',
body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Session Synology effacée',
body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} vous a assigné à {category} dans {trip}',
'notif.version_available.title': 'Nouvelle version disponible',
'notif.version_available.text': 'TREK {version} est maintenant disponible',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Voir le voyage',
'notif.action.view_collab': 'Voir les messages',
'notif.action.view_packing': 'Voir les bagages',
@@ -52,6 +52,12 @@ const gr: NotificationLocale = {
title: 'Νέα έκδοση TREK διαθέσιμη',
body: `Η έκδοση TREK ${p.version} είναι τώρα διαθέσιμη. Επισκεφθείτε τον πίνακα διαχείρισης για να ενημερώσετε.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Η σύνδεση Synology τερματίστηκε',
body: 'Ο λογαριασμός σας Synology ή το URL άλλαξε. Έχετε αποσυνδεθεί από το Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': 'Ο/Η {actor} σας ανέθεσε στο {category} του {trip}',
'notif.version_available.title': 'Νέα Έκδοση Διαθέσιμη',
'notif.version_available.text': 'Το TREK {version} είναι τώρα διαθέσιμο',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Προβολή Ταξιδιού',
'notif.action.view_collab': 'Προβολή Μηνυμάτων',
'notif.action.view_packing': 'Προβολή Αποσκευών',
@@ -52,6 +52,12 @@ const hu: NotificationLocale = {
title: 'Új TREK verzió érhető el',
body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology munkamenet törölve',
body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} hozzárendelte Önt a {category} kategóriához a(z) {trip} utazásban',
'notif.version_available.title': 'Új verzió elérhető',
'notif.version_available.text': 'A TREK {version} elérhető',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Utazás megtekintése',
'notif.action.view_collab': 'Üzenetek megtekintése',
'notif.action.view_packing': 'Csomagolás megtekintése',
@@ -52,6 +52,12 @@ const id: NotificationLocale = {
title: 'Versi TREK baru tersedia',
body: `TREK ${p.version} sekarang tersedia. Kunjungi panel admin untuk memperbarui.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Sesi Synology dihapus',
body: 'Akun atau URL Synology Anda berubah. Anda telah keluar dari Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} menugaskanmu ke {category} di {trip}',
'notif.version_available.title': 'Versi Baru Tersedia',
'notif.version_available.text': 'TREK {version} kini tersedia',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Lihat Perjalanan',
'notif.action.view_collab': 'Lihat Pesan',
'notif.action.view_packing': 'Lihat Perlengkapan',
@@ -52,6 +52,12 @@ const it: NotificationLocale = {
title: 'Nuova versione TREK disponibile',
body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Sessione Synology rimossa',
body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} ti ha assegnato a {category} in {trip}',
'notif.version_available.title': 'Nuova versione disponibile',
'notif.version_available.text': 'TREK {version} è ora disponibile',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Vedi viaggio',
'notif.action.view_collab': 'Vedi messaggi',
'notif.action.view_packing': 'Vedi bagagli',
@@ -52,6 +52,12 @@ const ja: NotificationLocale = {
title: '新しいTREKバージョンが利用可能',
body: `TREK ${p.version}が利用可能になりました。管理パネルからアップデートしてください。`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synologyセッションがクリアされました',
body: 'SynologyアカウントまたはURLが変更されました。Synology Photosからログアウトされました。',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor}が「{trip}」の{category}をあなたに割り当てました',
'notif.version_available.title': '新しいバージョンがあります',
'notif.version_available.text': 'TREK {version}が利用可能です',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': '旅行を見る',
'notif.action.view_collab': 'メッセージを見る',
'notif.action.view_packing': '持ち物を見る',
@@ -52,6 +52,12 @@ const ko: NotificationLocale = {
title: '새 TREK 버전 사용 가능',
body: `TREK ${p.version}을 사용할 수 있습니다. 관리자 패널에서 업데이트하세요.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology 세션이 초기화되었습니다',
body: 'Synology 계정 또는 URL이 변경되었습니다. Synology Photos에서 로그아웃되었습니다.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor}이(가) {trip}의 {category}에 배정했습니다',
'notif.version_available.title': '새 버전 사용 가능',
'notif.version_available.text': 'TREK {version}이(가) 사용 가능합니다',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': '여행 보기',
'notif.action.view_collab': '메시지 보기',
'notif.action.view_packing': '짐 목록 보기',
@@ -52,6 +52,12 @@ const nl: NotificationLocale = {
title: 'Nieuwe TREK-versie beschikbaar',
body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology-sessie gewist',
body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} heeft je toegewezen aan {category} in {trip}',
'notif.version_available.title': 'Nieuwe versie beschikbaar',
'notif.version_available.text': 'TREK {version} is nu beschikbaar',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Reis bekijken',
'notif.action.view_collab': 'Berichten bekijken',
'notif.action.view_packing': 'Paklijst bekijken',
@@ -52,6 +52,12 @@ const pl: NotificationLocale = {
title: 'Nowa wersja TREK dostępna',
body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Sesja Synology wyczyszczona',
body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} przypisał Cię do {category} w {trip}',
'notif.version_available.title': 'Nowa wersja dostępna',
'notif.version_available.text': 'TREK {version} jest teraz dostępny',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Zobacz podróż',
'notif.action.view_collab': 'Zobacz wiadomości',
'notif.action.view_packing': 'Zobacz pakowanie',
@@ -52,6 +52,12 @@ const ru: NotificationLocale = {
title: 'Доступна новая версия TREK',
body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Сессия Synology сброшена',
body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} назначил вас в {category} в {trip}',
'notif.version_available.title': 'Доступна новая версия',
'notif.version_available.text': 'TREK {version} теперь доступен',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Открыть поездку',
'notif.action.view_collab': 'Открыть сообщения',
'notif.action.view_packing': 'Открыть упаковку',
@@ -52,6 +52,12 @@ const en: NotificationLocale = {
title: 'Ny TREK version tillgänglig',
body: `TREK ${p.version} är nu tillgänglig. Gå till adminpanelen för att uppdatera.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology session rensad',
body: 'Ditt Synology-konto eller din webbadress har ändrats. Du har loggats ut från Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} har tilldelat dig {category} i {trip}',
'notif.version_available.title': 'Ny version tillgänglig',
'notif.version_available.text': 'TREK {version} finns nu tillgängligt',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Visa resan',
'notif.action.view_collab': 'Visa meddelanden',
'notif.action.view_packing': 'Visa förpackning',
@@ -52,6 +52,12 @@ const tr: NotificationLocale = {
title: 'Yeni TREK sürümü mevcut',
body: `TREK ${p.version} artık mevcut. Güncellemek için yönetici panelini ziyaret edin.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology oturumu temizlendi',
body: 'Synology hesabınız veya URL değişti. Synology Photos oturumunuz kapatıldı.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} sizi {trip} içinde {category} kategorisine atadı',
'notif.version_available.title': 'Yeni Sürüm Mevcut',
'notif.version_available.text': 'TREK {version} artık kullanılabilir',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Seyahati Görüntüle',
'notif.action.view_collab': 'Mesajları Görüntüle',
'notif.action.view_packing': 'Paket listesini görüntüle',
@@ -52,6 +52,12 @@ const uk: NotificationLocale = {
title: 'Доступна нова версія TREK',
body: `TREK ${p.version} тепер доступний. Перейдіть до панелі адміністратора для оновлення.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Сеанс Synology скинуто',
body: 'Ваш обліковий запис або URL Synology змінився. Ви вийшли з Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} призначив вас у {category} у {trip}',
'notif.version_available.title': 'Доступна нова версія',
'notif.version_available.text': 'TREK {version} тепер доступний',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Відкрити поїздку',
'notif.action.view_collab': 'Відкрити повідомлення',
'notif.action.view_packing': 'Відкрити пакування',
@@ -52,6 +52,12 @@ const en: NotificationLocale = {
title: 'Đã có phiên bản TREK mới',
body: `TREK ${p.version} có bản mới. Vui lòng truy cập bảng điều khiển quản trị để cập nhật.`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Đã xóa phiên Synology',
body: 'Tài khoản Synology của bạn hoặc URL đã thay đổi. Bạn đã đăng xuất khỏi Synology Photos.',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} đã giao cho bạn {category} trong {trip}',
'notif.version_available.title': 'Phiên bản mới có sẵn',
'notif.version_available.text': 'TREK {version} hiện có sẵn',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': 'Xem chuyến đi',
'notif.action.view_collab': 'Xem tin nhắn',
'notif.action.view_packing': 'Xem đóng gói',
@@ -52,6 +52,12 @@ const zhTW: NotificationLocale = {
title: '新版 TREK 可用',
body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology 工作階段已清除',
body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} 在 {trip} 中將 {category} 指派給你',
'notif.version_available.title': '有新版本可用',
'notif.version_available.text': 'TREK {version} 現已推出',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': '查看旅行',
'notif.action.view_collab': '查看訊息',
'notif.action.view_packing': '查看打包',
@@ -52,6 +52,12 @@ const zh: NotificationLocale = {
title: '新版 TREK 可用',
body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。`,
}),
replica_failure: (p) => ({
title: 'Storage replica failure',
body:
`Replica write failed on '${p.backend}': ${p.op} of ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` ${p.suppressed} more failures were suppressed since the last notification.` : ''),
}),
synology_session_cleared: () => ({
title: 'Synology 会话已清除',
body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。',
+2
View File
@@ -27,6 +27,8 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} 将您分配到 {trip} 中的 {category}',
'notif.version_available.title': '新版本可用',
'notif.version_available.text': 'TREK {version} 现已可用',
'notif.replica_failure.title': 'Storage replica failure',
'notif.replica_failure.text': "Replica write failed on '{backend}': {op} of {key} — {error}",
'notif.action.view_trip': '查看旅行',
'notif.action.view_collab': '查看消息',
'notif.action.view_packing': '查看行李',