mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-05 04:04:07 +00:00
fix(storage): let the app boot with the reclaim routes and keep the ratchets where they were
The two admin routes go on the managed allow list, without which buildApp refused to start. The reclaim calls move to their own api module so client.ts stays at its size, the phone card sizes its text through the type tiers, and the tests drop a hand-built service, a non-null assertion and a promise read as a boolean.
This commit is contained in:
@@ -56,8 +56,6 @@ import {
|
||||
type BookingImportMode,
|
||||
type ReceiptScanResult,
|
||||
type StorageAdminState,
|
||||
type StorageReclaimReport,
|
||||
type StorageReclaimState,
|
||||
type StorageBackend,
|
||||
type StorageConfigPut,
|
||||
type StorageTestResponse,
|
||||
@@ -736,12 +734,6 @@ export const adminApi = {
|
||||
// A full scan of a large install can exceed the 8s instance timeout.
|
||||
refreshStorageStats: (): Promise<StorageUsage> =>
|
||||
apiClient.post('/admin/storage/stats/refresh', undefined, { timeout: 120_000 }).then(r => r.data),
|
||||
setStorageReclaimDelete: (deleteEnabled: boolean): Promise<StorageReclaimState> =>
|
||||
apiClient.put('/admin/storage/reclaim', { deleteEnabled }).then(r => r.data),
|
||||
// A check lists every reclaimable category and asks each domain about it; a
|
||||
// large bucket outlasts the 8s instance timeout. It only reports, whatever the switch says.
|
||||
runStorageReclaimCheck: (): Promise<StorageReclaimReport> =>
|
||||
apiClient.post('/admin/storage/reclaim/run', { reportOnly: true }, { timeout: 300_000 }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { StorageReclaimReport, StorageReclaimState } from '@trek/shared';
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** Admin > Storage, unreferenced files: the nightly deletion switch and a check on demand. */
|
||||
export const storageReclaimApi = {
|
||||
setDelete: (deleteEnabled: boolean): Promise<StorageReclaimState> =>
|
||||
apiClient.put('/admin/storage/reclaim', { deleteEnabled }).then((r) => r.data),
|
||||
// A check lists every reclaimable category and asks each domain about it; a
|
||||
// large bucket outlasts the 8s instance timeout. It only reports, whatever the switch says.
|
||||
check: (): Promise<StorageReclaimReport> =>
|
||||
apiClient.post('/admin/storage/reclaim/run', { reportOnly: true }, { timeout: 300_000 }).then((r) => r.data),
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { storageReclaimApi } from '../../../api/storageReclaim'
|
||||
import { getApiErrorMessage, type ApiError } from '../../../types'
|
||||
import { settingsDocumentOf } from './storageModel'
|
||||
|
||||
@@ -371,7 +372,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
|
||||
const setReclaimDelete = useCallback(async (enabled: boolean): Promise<string | null> => {
|
||||
try {
|
||||
setReclaim(await adminApi.setStorageReclaimDelete(enabled))
|
||||
setReclaim(await storageReclaimApi.setDelete(enabled))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
@@ -380,7 +381,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
|
||||
const checkReclaim = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const lastRun = await adminApi.runStorageReclaimCheck()
|
||||
const lastRun = await storageReclaimApi.check()
|
||||
setReclaim({ deleteEnabled: stateRef.current?.reclaim?.deleteEnabled ?? false, lastRun })
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { ReclaimCount, ReclaimView } from '../../../components/Admin/storage/reclaimModel';
|
||||
import { fs } from '../../../components/shared/DialogShell';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
import { relativeTime } from '../../../utils/relativeTime';
|
||||
@@ -39,13 +40,13 @@ export default function MStorageReclaimCard({ reclaim, checking, onCheck, onDele
|
||||
}
|
||||
/>
|
||||
<div className="border-t border-[color:var(--m-rowbr)] pt-[11px]" data-testid="m-storage-reclaim-summary">
|
||||
<p className="font-geist text-[0.625rem] text-m-muted">
|
||||
<p className="font-geist text-m-muted" style={fs(10)}>
|
||||
{reclaim.checkedAt !== null
|
||||
? t('storage.reclaim.checked', { age: relativeTime(reclaim.checkedAt, locale) })
|
||||
: t('storage.reclaim.never')}
|
||||
</p>
|
||||
{reclaim.stopped && (
|
||||
<p role="note" className="mt-1 font-geist text-[0.625rem] text-m-muted">
|
||||
<p role="note" className="mt-1 font-geist text-m-muted" style={fs(10)}>
|
||||
{t('storage.reclaim.stopped')}
|
||||
</p>
|
||||
)}
|
||||
@@ -57,24 +58,30 @@ export default function MStorageReclaimCard({ reclaim, checking, onCheck, onDele
|
||||
</div>
|
||||
)}
|
||||
{checked && reclaim.lines.length === 0 && (
|
||||
<p className="mt-2 font-geist text-[0.625rem] text-m-muted">{t('storage.reclaim.nothing')}</p>
|
||||
<p className="mt-2 font-geist text-m-muted" style={fs(10)}>
|
||||
{t('storage.reclaim.nothing')}
|
||||
</p>
|
||||
)}
|
||||
{reclaim.lines.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{reclaim.lines.map((line) => (
|
||||
<div key={line.category} data-testid={`m-storage-reclaim-${line.category}`}>
|
||||
<p className="text-[0.75rem] font-bold text-m-ink">{t(`storage.category.${line.category}`)}</p>
|
||||
<p className="font-bold text-m-ink" style={fs(12, 'body')}>
|
||||
{t(`storage.category.${line.category}`)}
|
||||
</p>
|
||||
{line.skipped ? (
|
||||
<p className="font-geist text-[0.625rem] text-m-muted">{t(`storage.reclaim.skip.${line.skipped}`)}</p>
|
||||
<p className="font-geist text-m-muted" style={fs(10)}>
|
||||
{t(`storage.reclaim.skip.${line.skipped}`)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{line.freeable.objects > 0 && (
|
||||
<p className="font-geist text-[0.625rem] text-m-muted">
|
||||
<p className="font-geist text-m-muted" style={fs(10)}>
|
||||
{t('storage.reclaim.freeable')}: {count(line.freeable)}
|
||||
</p>
|
||||
)}
|
||||
{line.waiting.objects > 0 && (
|
||||
<p className="font-geist text-[0.625rem] text-m-muted">
|
||||
<p className="font-geist text-m-muted" style={fs(10)}>
|
||||
{t('storage.reclaim.waiting')}: {count(line.waiting)}
|
||||
</p>
|
||||
)}
|
||||
@@ -92,8 +99,12 @@ export default function MStorageReclaimCard({ reclaim, checking, onCheck, onDele
|
||||
function Figure({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-geist text-[0.6875rem] text-m-muted">{label}</span>
|
||||
<span className="font-geist text-[0.6875rem] font-bold tabular-nums text-m-ink">{value}</span>
|
||||
<span className="font-geist text-m-muted" style={fs(11)}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-geist font-bold tabular-nums text-m-ink" style={fs(11)}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,8 @@ export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
|
||||
'StorageAdminController.statsRefresh',
|
||||
'StorageAdminController.test',
|
||||
'StorageAdminController.update',
|
||||
'StorageReclaimController.run',
|
||||
'StorageReclaimController.update',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,13 +7,14 @@ import * as backupSvc from '../../../src/nest/backup/backup.impl';
|
||||
import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service';
|
||||
import type { BackupService } from '../../../src/nest/backup/backup.service';
|
||||
import { StorageJobsService } from '../../../src/nest/storage/storage-jobs.service';
|
||||
import type { StorageRegistryService } from '../../../src/nest/storage/storage-registry.service';
|
||||
import { StorageRegistryService } from '../../../src/nest/storage/storage-registry.service';
|
||||
import { StorageNotFoundError } from '../../../src/nest/storage/storage.types';
|
||||
import type { User } from '../../../src/types';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
|
||||
@@ -376,9 +377,16 @@ describe('BackupService (wrapper)', () => {
|
||||
const database = {
|
||||
__sentinel: 'database',
|
||||
} as unknown as import('../../../src/nest/database/database-backup.interface').DatabaseBackupStrategy;
|
||||
// The real one-job slot: the restore marks itself running there.
|
||||
const jobs = new StorageJobsService({} as StorageRegistryService);
|
||||
const wrapper = new RealBackupService(storage, database, jobs);
|
||||
// The real one-job slot, taken from a testing module: the restore marks itself running there.
|
||||
let jobs: StorageJobsService;
|
||||
let wrapper: RealBackupService;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [StorageJobsService, { provide: StorageRegistryService, useValue: {} }],
|
||||
}).compile();
|
||||
jobs = moduleRef.get(StorageJobsService);
|
||||
wrapper = new RealBackupService(storage, database, jobs);
|
||||
});
|
||||
|
||||
it('forwards every call straight to the legacy backup service', async () => {
|
||||
expect(await wrapper.listBackups()).toEqual([{ filename: 'svc.zip' }]);
|
||||
|
||||
@@ -65,8 +65,9 @@ describe('StorageReclaimJob', () => {
|
||||
const { job, registrar, reclaim } = makeJob(() => new Promise((resolve) => (finish = resolve)));
|
||||
job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('storage-reclaim', '5 4 * * *', expect.any(Function));
|
||||
const onTick = registrar.register.mock.calls[0]![2];
|
||||
const ticking = onTick();
|
||||
const [call] = registrar.register.mock.calls;
|
||||
if (!call) throw new Error('the job registered nothing');
|
||||
const ticking = call[2]();
|
||||
expect(ticking).toBeInstanceOf(Promise);
|
||||
let settled = false;
|
||||
void (ticking as Promise<void>).then(() => (settled = true));
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('RECLAIM_LEDGER', () => {
|
||||
for (const category of ['photos', 'photos-google', 'photos-trek', 'backups'] as const) {
|
||||
const rule: ReclaimRule = RECLAIM_LEDGER[category];
|
||||
expect(rule.reclaim).toBe(false);
|
||||
if (!rule.reclaim) expect(rule.reason.length).toBeGreaterThan(10);
|
||||
if (rule.reclaim === false) expect(rule.reason.length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -497,9 +497,11 @@ describe('StorageReclaimService: the switch and the stored report', () => {
|
||||
await reclaim.run({ now: T0 });
|
||||
const jobs = t.get(StorageJobsService);
|
||||
let restoreStarted: Promise<void> | null = null;
|
||||
let restoreAsked = false;
|
||||
let restoreWent = false;
|
||||
onAsk = (_category, keys) => {
|
||||
if (keys.length !== 1 || restoreStarted) return;
|
||||
if (keys.length !== 1 || restoreAsked) return;
|
||||
restoreAsked = true;
|
||||
restoreStarted = jobs.beginRestore().then(() => {
|
||||
restoreWent = true;
|
||||
});
|
||||
@@ -508,7 +510,7 @@ describe('StorageReclaimService: the switch and the stored report', () => {
|
||||
const report = await reclaim.run({ now: WEEK_LATER });
|
||||
expect(report.stopped).toBe('database_reopened');
|
||||
expect(fs.existsSync(objectPath('covers', 'orphan.jpg'))).toBe(true);
|
||||
expect(restoreStarted).not.toBeNull();
|
||||
expect(restoreAsked).toBe(true);
|
||||
await restoreStarted;
|
||||
expect(restoreWent).toBe(true);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user