From 0d9386f7159ecba3ce4a3b1275de4b9ac0db84fe Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 20 Aug 2026 14:01:39 +0200 Subject: [PATCH] feat(client): storage admin draft-document helpers + relative-time util --- .../Admin/storage/storageModel.test.ts | 104 ++++++++++++++++++ .../components/Admin/storage/storageModel.ts | 74 +++++++++++++ client/src/utils/relativeTime.test.ts | 21 ++++ client/src/utils/relativeTime.ts | 15 +++ 4 files changed, 214 insertions(+) create mode 100644 client/src/components/Admin/storage/storageModel.test.ts create mode 100644 client/src/components/Admin/storage/storageModel.ts create mode 100644 client/src/utils/relativeTime.test.ts create mode 100644 client/src/utils/relativeTime.ts diff --git a/client/src/components/Admin/storage/storageModel.test.ts b/client/src/components/Admin/storage/storageModel.test.ts new file mode 100644 index 000000000..ad2b52c03 --- /dev/null +++ b/client/src/components/Admin/storage/storageModel.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { MASKED_SETTING_VALUE, type StorageAdminState, type StorageConfig } from '@trek/shared'; +import { + categoriesPointingAt, + hasPlaintextSecret, + mirrorsReferencing, + removeBackend, + settingsDocumentOf, + upsertBackend, +} from './storageModel'; + +const STATE: StorageAdminState = { + backends: [ + { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] }, + { name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] }, + { + name: 'off-box', + type: 's3', + source: 'settings', + options: { + endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', + secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000, + }, + categories: ['covers'], + }, + ], + categories: { + files: { backend: 'uploads-local', source: 'default' }, + journey: { backend: 'uploads-local', source: 'default' }, + covers: { backend: 'off-box', source: 'settings' }, + avatars: { backend: 'uploads-local', source: 'default' }, + places: { backend: 'place-photos-local', source: 'default' }, + photos: { backend: 'uploads-local', source: 'default' }, + 'photos-google': { backend: 'place-photos-local', source: 'default' }, + 'photos-trek': { backend: 'uploads-local', source: 'default' }, + backups: { backend: 'backups-local', source: 'default' }, + }, + health: { replicaFailures: [] }, + encryptionReady: true, + seedFilePresent: false, +}; + +describe('settingsDocumentOf', () => { + it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => { + const doc = settingsDocumentOf(STATE); + expect(doc.backends.map((b) => b.name)).toEqual(['off-box']); + expect(doc.categories).toEqual({ covers: 'off-box' }); + }); +}); + +describe('remove pre-check helpers', () => { + const draft: StorageConfig = { + backends: [ + { name: 'nas', type: 'local', options: { root: '/mnt/nas' } }, + { name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } }, + ], + categories: { covers: 'nas', backups: 'mir' }, + }; + + it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => { + expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']); + expect(categoriesPointingAt(draft, 'unused')).toEqual([]); + }); + + it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => { + expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']); + expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']); + expect(mirrorsReferencing(draft, 'unused')).toEqual([]); + }); +}); + +describe('hasPlaintextSecret', () => { + const s3 = (secretAccessKey: string) => ({ + type: 's3' as const, + options: { endpoint: 'http://x', bucket: 'b', accessKeyId: 'ak', secretAccessKey }, + }); + + it('FE-ADMIN-STORM-004: mask and empty are not plaintext; anything else is; local never is', () => { + expect(hasPlaintextSecret(s3(MASKED_SETTING_VALUE))).toBe(false); + expect(hasPlaintextSecret(s3(''))).toBe(false); + expect(hasPlaintextSecret(s3('sk-plain'))).toBe(true); + expect(hasPlaintextSecret({ type: 'local', options: { root: '/x' } })).toBe(false); + }); +}); + +describe('draft edits', () => { + const draft: StorageConfig = { + backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }], + categories: {}, + }; + + it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => { + const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }); + expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]); + const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } }); + expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']); + expect(draft.backends).toHaveLength(1); // untouched + }); + + it('FE-ADMIN-STORM-006: removeBackend filters by name', () => { + expect(removeBackend(draft, 'nas').backends).toEqual([]); + expect(removeBackend(draft, 'ghost').backends).toHaveLength(1); + }); +}); diff --git a/client/src/components/Admin/storage/storageModel.ts b/client/src/components/Admin/storage/storageModel.ts new file mode 100644 index 000000000..25140b048 --- /dev/null +++ b/client/src/components/Admin/storage/storageModel.ts @@ -0,0 +1,74 @@ +import { + MASKED_SETTING_VALUE, + storageSecretFields, + type StorageAdminState, + type StorageBackend, + type StorageCategory, + type StorageConfig, +} from '@trek/shared' + +/** One entry of the effective world (GET state), as the panels render it. */ +export type StateBackend = StorageAdminState['backends'][number] + +/** + * The settings-owned document the PUT carries: settings-sourced backends and + * settings-sourced category assignments ONLY — built-ins and env backends are + * never in the body unless the operator overrides one by name (in which case + * the override already reports source 'settings'). + */ +export function settingsDocumentOf(state: StorageAdminState): StorageConfig { + return { + backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend), + categories: Object.fromEntries( + Object.entries(state.categories) + .filter(([, entry]) => entry.source === 'settings') + .map(([category, entry]) => [category, entry.backend]), + ) as StorageConfig['categories'], + } +} + +/** + * State options are already schema-shaped (the wire contract guarantees it; + * secret fields carry the mask, which the server unmasks by name on PUT/test) — + * the cast re-attaches the discriminated-union type the loose state record dropped. + */ +export function asWireBackend(backend: Pick): StorageBackend { + return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend +} + +/** Draft categories pointing at a backend name (the friendly remove pre-check). */ +export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] { + return (Object.entries(draft.categories) as Array<[StorageCategory, string]>) + .filter(([, backend]) => backend === name) + .map(([category]) => category) +} + +/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */ +export function mirrorsReferencing(draft: StorageConfig, name: string): string[] { + return draft.backends + .filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name))) + .map((b) => b.name) +} + +/** True when a secret-kind field holds a non-empty value that is not the mask sentinel. */ +export function hasPlaintextSecret(backend: { type: StorageBackend['type']; options: Record }): boolean { + return storageSecretFields(backend.type).some((field) => { + const value = backend.options[field] + return typeof value === 'string' && value !== '' && value !== MASKED_SETTING_VALUE + }) +} + +/** Replace by name in place, or append. Never mutates. */ +export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig { + const exists = draft.backends.some((b) => b.name === backend.name) + return { + ...draft, + backends: exists + ? draft.backends.map((b) => (b.name === backend.name ? backend : b)) + : [...draft.backends, backend], + } +} + +export function removeBackend(draft: StorageConfig, name: string): StorageConfig { + return { ...draft, backends: draft.backends.filter((b) => b.name !== name) } +} diff --git a/client/src/utils/relativeTime.test.ts b/client/src/utils/relativeTime.test.ts new file mode 100644 index 000000000..698524800 --- /dev/null +++ b/client/src/utils/relativeTime.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { relativeTime } from './relativeTime'; + +describe('relativeTime', () => { + const now = 1_700_000_000_000; + + it('FE-UTIL-RELTIME-001: formats seconds, minutes, hours and days', () => { + expect(relativeTime(now - 30_000, 'en', now)).toBe('30 seconds ago'); + expect(relativeTime(now - 120_000, 'en', now)).toBe('2 minutes ago'); + expect(relativeTime(now - 2 * 3_600_000, 'en', now)).toBe('2 hours ago'); + expect(relativeTime(now - 3 * 86_400_000, 'en', now)).toBe('3 days ago'); + }); + + it('FE-UTIL-RELTIME-002: clamps future timestamps to "now"', () => { + expect(relativeTime(now + 5_000, 'en', now)).toBe('now'); + }); + + it('FE-UTIL-RELTIME-003: localizes via Intl (de)', () => { + expect(relativeTime(now - 120_000, 'de', now)).toBe('vor 2 Minuten'); + }); +}); diff --git a/client/src/utils/relativeTime.ts b/client/src/utils/relativeTime.ts new file mode 100644 index 000000000..8c46cb9e9 --- /dev/null +++ b/client/src/utils/relativeTime.ts @@ -0,0 +1,15 @@ +const STEPS: Array<{ limit: number; div: number; unit: Intl.RelativeTimeFormatUnit }> = [ + { limit: 60_000, div: 1_000, unit: 'second' }, + { limit: 3_600_000, div: 60_000, unit: 'minute' }, + { limit: 86_400_000, div: 3_600_000, unit: 'hour' }, + { limit: Number.POSITIVE_INFINITY, div: 86_400_000, unit: 'day' }, +] + +/** "2 minutes ago" from an epoch-ms timestamp, localized. Future stamps clamp to "now". */ +export function relativeTime(at: number, locale: string, now: number = Date.now()): string { + const elapsed = now - at + if (elapsed < 1_000) return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(0, 'second') + const step = STEPS.find((s) => elapsed < s.limit)! + const value = Math.round(elapsed / step.div) + return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-value, step.unit) +}