feat: setup logs retention by max file size

This commit is contained in:
fsylvain committed 2026-10-07 22:02:13 +02:00
1 parent 70c8492f4f
commit f914eae3c5
44 files changed
+2316 -161

No files matched your search

@@ -30,6 +30,8 @@ function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings
return {
version: 'v2.2.0-beta',
activity_retention_days: 90,
activity_retention_max_gb: 0,
activity_log_size_bytes: 0,
activity_last_cleanup_at: at(6, 3),
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3),
@@ -116,14 +118,26 @@ async function choose(days: number) {
await flushPromises()
}
function selectedLabel() {
const select = wrapper!.findComponent(USelectMenu)
function labelOf(select: ReturnType<VueWrapper['findComponent']>) {
const value = select.props('modelValue')
return (select.props('items') as { value: number; label: string }[]).find(
(item) => item.value === value,
)?.label
}
function selectedLabel() {
return labelOf(wrapper!.findAllComponents(USelectMenu)[0]!)
}
function sizeSelectLabel() {
return labelOf(wrapper!.findAllComponents(USelectMenu)[1]!)
}
async function chooseSize(gb: number) {
wrapper!.findAllComponents(USelectMenu)[1]!.vm.$emit('update:modelValue', gb)
await flushPromises()
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
@@ -153,7 +167,7 @@ describe('Settings retention, Default with 90 days', () => {
await mountPage('owner', () => Promise.resolve(settings()))
expect(text()).toContain('Retention')
expect(text()).toContain('How long activity is kept.')
expect(text()).toContain('How long activity is kept, and how much log data to keep.')
expect(selectedLabel()).toBe('90 days')
expect(text()).toContain('Older events are removed automatically.')
expect(text()).toContain('Activity logs are append-only')
@@ -476,3 +490,342 @@ describe('Settings retention, the French interface', () => {
expect(selectedLabel()).toBe('90 jours')
})
})
const FIVE_GB_SETTINGS = {
activity_retention_max_gb: 5,
activity_log_size_bytes: 3435973837,
}
describe('Settings retention size limit, states', () => {
it('Default with 90 days or 5 GB', async () => {
await mountPage('owner', () => Promise.resolve(settings(FIVE_GB_SETTINGS)))
expect(selectedLabel()).toBe('90 days')
expect(sizeSelectLabel()).toBe('5 GB')
expect(text()).toContain('Events older than this are removed.')
expect(text()).toContain('Keep at most')
expect(text()).toContain('or')
expect(text()).toContain(
'When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB.',
)
expect(text()).toContain(
"Events can't be edited or deleted from the app. Only the retention limits remove them.",
)
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
})
it('Age only (no size limit)', async () => {
await mountPage('owner', () => Promise.resolve(settings()))
expect(sizeSelectLabel()).toBe('No size limit')
expect(text()).toContain('Optional. Logs can grow without a size cap.')
expect(text()).toContain('Older events are removed automatically.')
})
it('Size only (forever)', async () => {
await mountPage('owner', () =>
Promise.resolve(settings({ ...FIVE_GB_SETTINGS, activity_retention_days: 0 })),
)
expect(selectedLabel()).toBe('Forever')
expect(text()).toContain('No age limit. Only the size limit applies.')
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
})
it('Forever with no size limit', async () => {
await mountPage('owner', () =>
Promise.resolve(
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
),
)
expect(text()).toContain('Nothing is removed automatically. The log grows over time.')
expect(line('retention-next')).toBe('Not scheduled')
})
it.each([
[3435973837, 'Currently 3.2 GB.'],
[0, 'Currently Less than 0.1 GB.'],
[53687091, 'Currently Less than 0.1 GB.'],
[5368709120, 'Currently 5.0 GB.'],
])('formats %s bytes as "%s"', async (bytes, shown) => {
await mountPage('owner', () =>
Promise.resolve(
settings({ activity_retention_max_gb: 5, activity_log_size_bytes: bytes }),
),
)
expect(text()).toContain(shown)
})
})
describe('Settings retention size limit, a larger size or no limit saves at once', () => {
it.each([
[5, 10],
[5, 0],
[1, 50],
])('saves %s GB to %s with one PATCH of the size key only', async (from, to) => {
vi.useFakeTimers()
const pending = deferred<unknown>()
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: from })),
() => pending.promise,
)
await chooseSize(to)
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: to }))
expect(text()).toContain('Saving…')
expect(text()).not.toContain('Events over')
pending.resolve(settings({ activity_retention_max_gb: to }))
await flushPromises()
expect(text()).toContain('Saved')
expect(sizeSelectLabel()).toBe(to === 0 ? 'No size limit' : `${to} GB`)
await vi.advanceTimersByTimeAsync(SAVED_VISIBLE_MS)
expect(text()).not.toContain('Saved')
})
})
describe('Settings retention size limit, a smaller size asks first', () => {
it.each([
[10, 5],
[0, 5],
[50, 1],
])('warns for %s GB then %s GB without sending', async (from, to) => {
await mountPage('owner', () =>
Promise.resolve(settings({ activity_retention_max_gb: from })),
)
await chooseSize(to)
expect(patches()).toHaveLength(0)
expect(sizeSelectLabel()).toBe(`${to} GB`)
expect(text()).toContain(`Events over ${to} GB will be removed`)
expect(text()).toContain(
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
)
expect(button(`Apply ${to} GB`)).toBeDefined()
expect(button('Cancel')).toBeDefined()
})
it('applies with one PATCH and cancels without any request', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
)
await chooseSize(5)
button('Apply 5 GB')!.click()
await flushPromises()
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 5 }))
expect(text()).toContain('Saved')
expect(text()).not.toContain('Events over')
await chooseSize(1)
button('Cancel')!.click()
await flushPromises()
expect(sizeSelectLabel()).toBe('5 GB')
expect(patches()).toHaveLength(1)
})
})
describe('Settings retention size limit, one warning at a time', () => {
it('replaces the age warning with the size warning and reverts the age select', async () => {
await mountPage('owner', () => Promise.resolve(settings({ activity_retention_max_gb: 10 })))
await choose(30)
await chooseSize(5)
expect(text()).toContain('Events over 5 GB will be removed')
expect(text()).not.toContain('Events older than 30 days')
expect(selectedLabel()).toBe('90 days')
})
it('drops the warning when a larger size is chosen', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() => Promise.resolve(settings({ activity_retention_max_gb: 25 })),
)
await chooseSize(5)
await chooseSize(25)
expect(text()).not.toContain('Events over')
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 25 }))
expect(sizeSelectLabel()).toBe('25 GB')
})
it('drops a size warning for a longer age and sends only the age', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() =>
Promise.resolve(
settings({ activity_retention_days: 365, activity_retention_max_gb: 10 }),
),
)
await chooseSize(5)
await choose(365)
expect(text()).not.toContain('Events over')
expect(sizeSelectLabel()).toBe('10 GB')
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
})
})
describe('Settings retention size limit, switching age to Forever', () => {
it('sends the age only and keeps the schedule', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings(FIVE_GB_SETTINGS)),
() =>
Promise.resolve(
settings({
...FIVE_GB_SETTINGS,
activity_retention_days: 0,
activity_next_cleanup_at: at(8, 3),
}),
),
)
await choose(0)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 0 }))
expect(text()).toContain('No age limit. Only the size limit applies.')
expect(line('retention-next')).toBe('8 Oct 2026, 03:00')
})
})
describe('Settings retention size limit, save errors revert only the size select', () => {
it.each([
['500', () => Promise.reject(new ApiError(500))],
['400', () => Promise.reject(new ApiError(400))],
['403', () => Promise.reject(new ApiError(403))],
['network', () => Promise.reject(new NetworkError())],
])('on %s', async (_name, failure) => {
let attempt = 0
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
() => {
attempt += 1
return attempt === 1
? failure()
: Promise.resolve(settings({ activity_retention_max_gb: 10 }))
},
)
await chooseSize(10)
expect(text()).toContain("Couldn't save the retention period")
expect(sizeSelectLabel()).toBe('5 GB')
expect(selectedLabel()).toBe('90 days')
expect(text()).not.toContain('Saved')
Array.from(retention()!.querySelectorAll('button'))
.find((candidate) => candidate.textContent?.trim() === 'Try again')!
.click()
await flushPromises()
expect(patches()).toHaveLength(2)
expect(patches()[1]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 10 }))
expect(text()).toContain('Saved')
})
it('retries an applied smaller size without asking again', async () => {
let attempt = 0
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() => {
attempt += 1
return attempt === 1
? Promise.reject(new ApiError(500))
: Promise.resolve(settings({ activity_retention_max_gb: 5 }))
},
)
await chooseSize(5)
button('Apply 5 GB')!.click()
await flushPromises()
Array.from(retention()!.querySelectorAll('button'))
.find((candidate) => candidate.textContent?.trim() === 'Try again')!
.click()
await flushPromises()
expect(patches()).toHaveLength(2)
expect(text()).not.toContain('Events over')
})
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
() => Promise.reject(new ApiError(403)),
)
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
await chooseSize(10)
expect(retention()).toBeNull()
})
it('shows no card alert on a 401', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
() => Promise.reject(new ApiError(401)),
)
await chooseSize(10)
expect(text()).not.toContain("Couldn't save")
expect(sizeSelectLabel()).toBe('5 GB')
})
})
describe('Settings retention size limit, offline and French', () => {
it('disables both selects, Apply and Cancel offline', async () => {
await mountPage('owner', () => Promise.resolve(settings({ activity_retention_max_gb: 10 })))
await chooseSize(5)
const online = vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
const selects = wrapper!.findAllComponents(USelectMenu)
expect(selects[0]!.props('disabled')).toBe(true)
expect(selects[1]!.props('disabled')).toBe(true)
expect(button('Apply 5 GB')!.disabled).toBe(true)
expect(button('Cancel')!.disabled).toBe(true)
online.mockRestore()
})
it('words the size strings in French', async () => {
setLocale('fr')
await mountPage('owner', () => Promise.resolve(settings(FIVE_GB_SETTINGS)))
expect(text()).toContain('Conserver au maximum')
expect(text()).toContain('ou')
expect(sizeSelectLabel()).toBe('5 Go')
expect(text()).toContain(
'Au-delà de 5 Go, les événements les plus anciens sont supprimés. Actuellement 3,2 Go.',
)
await chooseSize(1)
expect(text()).toContain('Les événements au-delà de 1 Go seront supprimés')
expect(button('Appliquer 1 Go')).toBeDefined()
})
})