From eb998595d731d2652e503040d684b2baaf59549e Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 20 Aug 2026 22:30:19 +0200 Subject: [PATCH] feat(shared): backfill-status and usage wire schemas on the storage admin state --- .../Admin/storage/AdminStoragePanel.test.tsx | 2 + .../Admin/storage/storageModel.test.ts | 2 + .../mobile/admin/MAdminStoragePanel.test.tsx | 2 + .../src/nest/storage/storage-admin.service.ts | 3 ++ shared/src/storage/storage.schema.spec.ts | 37 +++++++++++++++++++ shared/src/storage/storage.schema.ts | 29 +++++++++++++++ 6 files changed, 75 insertions(+) diff --git a/client/src/components/Admin/storage/AdminStoragePanel.test.tsx b/client/src/components/Admin/storage/AdminStoragePanel.test.tsx index ef6c17d54..35db8dc5c 100644 --- a/client/src/components/Admin/storage/AdminStoragePanel.test.tsx +++ b/client/src/components/Admin/storage/AdminStoragePanel.test.tsx @@ -32,6 +32,8 @@ function baseState(overrides: Partial = {}): StorageAdminStat health: { replicaFailures: [] }, encryptionReady: true, seedFilePresent: false, + usage: null, + backfills: [], ...overrides, }; } diff --git a/client/src/components/Admin/storage/storageModel.test.ts b/client/src/components/Admin/storage/storageModel.test.ts index dd120be07..13f89c5a3 100644 --- a/client/src/components/Admin/storage/storageModel.test.ts +++ b/client/src/components/Admin/storage/storageModel.test.ts @@ -48,6 +48,8 @@ const STATE: StorageAdminState = { health: { replicaFailures: [] }, encryptionReady: true, seedFilePresent: false, + usage: null, + backfills: [], }; describe('settingsDocumentOf', () => { diff --git a/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx b/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx index f4a8e746a..a7a93d9cd 100644 --- a/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx @@ -30,6 +30,8 @@ function baseState(overrides: Partial = {}): StorageAdminStat health: { replicaFailures: [] }, encryptionReady: true, seedFilePresent: false, + usage: null, + backfills: [], ...overrides, }; } diff --git a/server/src/nest/storage/storage-admin.service.ts b/server/src/nest/storage/storage-admin.service.ts index c3ccdeec8..4ab497ff9 100644 --- a/server/src/nest/storage/storage-admin.service.ts +++ b/server/src/nest/storage/storage-admin.service.ts @@ -57,6 +57,9 @@ export class StorageAdminService { health: { replicaFailures: this.storage.health().replicaFailures.map((f) => ({ ...f })) }, encryptionReady: this.env.env().security.encryptionKeySet, seedFilePresent: fs.existsSync(SEED_CONFIG_PATH), + // Filled by the stats/jobs services — backfill/stats spec. + usage: null, + backfills: [], }; } diff --git a/shared/src/storage/storage.schema.spec.ts b/shared/src/storage/storage.schema.spec.ts index d36427f65..452fca7b8 100644 --- a/shared/src/storage/storage.schema.spec.ts +++ b/shared/src/storage/storage.schema.spec.ts @@ -2,8 +2,11 @@ import { STORAGE_BACKEND_TYPES, STORAGE_BACKEND_TYPE_IDS, STORAGE_CATEGORIES, + storageAdminStateSchema, + storageBackfillStatusSchema, storageConfigSchema, storageSecretFields, + storageUsageSchema, type StorageBackendTypeId, } from './storage.schema'; @@ -145,3 +148,37 @@ describe('storageConfigSchema wire compatibility', () => { expect(result.success).toBe(false); }); }); + +describe('backfill + usage wire shapes', () => { + it('storageBackfillStatusSchema round-trips a running and a finished status', () => { + const running = { + backend: 'backups-local-mirror', + status: 'running', + done: 3, + total: 10, + copied: 2, + skipped: 1, + failed: 0, + startedAt: 1_700_000_000_000, + }; + expect(storageBackfillStatusSchema.parse(running)).toEqual(running); + const done = { ...running, status: 'done', done: 10, finishedAt: 1_700_000_100_000 }; + expect(storageBackfillStatusSchema.parse(done)).toEqual(done); + expect(storageBackfillStatusSchema.safeParse({ ...running, status: 'paused' }).success).toBe(false); + }); + + it('storageUsageSchema carries all configurable categories plus the legacy photos bucket', () => { + const perCategory = { objects: 1, bytes: 2 }; + const usage = { + computedAt: 1_700_000_000_000, + categories: Object.fromEntries(STORAGE_CATEGORIES.map((c) => [c, perCategory])), + legacyPhotos: { objects: 0, bytes: 0 }, + }; + expect(storageUsageSchema.parse(usage)).toEqual(usage); + }); + + it('the admin state embeds usage (nullable) and backfills', () => { + const stateShape = storageAdminStateSchema.pick({ usage: true, backfills: true }); + expect(stateShape.parse({ usage: null, backfills: [] })).toEqual({ usage: null, backfills: [] }); + }); +}); diff --git a/shared/src/storage/storage.schema.ts b/shared/src/storage/storage.schema.ts index 759a99493..6cc8e813c 100644 --- a/shared/src/storage/storage.schema.ts +++ b/shared/src/storage/storage.schema.ts @@ -212,6 +212,33 @@ export const storageReplicaFailureSchema = z.object({ at: z.number(), }); +// ── Backfill + usage (backfill/stats/notifications spec) ───────────────────── + +export const storageBackfillStatusSchema = z.object({ + /** The mirror's wire name — user-facing copy says "Sync", never this. */ + backend: z.string(), + status: z.enum(['running', 'done', 'error', 'cancelled']), + /** Objects examined in the copy phase; total is 0 while enumerating. */ + done: z.number(), + total: z.number(), + copied: z.number(), + skipped: z.number(), + failed: z.number(), + startedAt: z.number(), + finishedAt: z.number().optional(), + /** status 'error' only: the aborting (primary-side) error. */ + error: z.string().optional(), +}); +export type StorageBackfillStatus = z.infer; + +export const storageUsageSchema = z.object({ + computedAt: z.number(), + categories: z.record(storageCategorySchema, z.object({ objects: z.number(), bytes: z.number() })), + /** The served-legacy photos directory — attributed to whatever backend `photos` resolves to. */ + legacyPhotos: z.object({ objects: z.number(), bytes: z.number() }), +}); +export type StorageUsage = z.infer; + export const storageAdminStateSchema = z.object({ backends: z.array( z.object({ @@ -231,6 +258,8 @@ export const storageAdminStateSchema = z.object({ health: z.object({ replicaFailures: z.array(storageReplicaFailureSchema) }), encryptionReady: z.boolean(), seedFilePresent: z.boolean(), + usage: storageUsageSchema.nullable(), + backfills: z.array(storageBackfillStatusSchema), }); export type StorageAdminState = z.infer;