feat(client): storage admin draft-document helpers + relative-time util

This commit is contained in:
jubnl
2026-08-20 14:01:39 +02:00
parent 376fb5bca4
commit 0d9386f715
4 changed files with 214 additions and 0 deletions
@@ -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);
});
});
@@ -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<StateBackend, 'name' | 'type' | 'options'>): 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<string, unknown> }): 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) }
}
+21
View File
@@ -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');
});
});
+15
View File
@@ -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)
}