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

+160 -59
View File
@@ -4,15 +4,18 @@ import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core'
import { ApiError } from '@/composables/useApi'
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
import { useSettings, type RetentionPatch, type WorkspaceSettings } from '@/composables/useSettings'
import {
SAVED_VISIBLE_MS,
helperCopy,
isShorter,
isSmaller,
lastCleanupLine,
nextCleanupLine,
retentionOptions,
shorteningDescription,
sizeHelperCopy,
sizeOptions,
} from '@/lib/activityRetention'
import { useSessionStore } from '@/stores/session'
@@ -24,6 +27,12 @@ const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
type SaveStatus = 'idle' | 'saving' | 'saved'
type Control = 'days' | 'gb'
interface Pending {
control: Control
value: number
}
const props = defineProps<{
settings: WorkspaceSettings | null
@@ -40,9 +49,10 @@ const session = useSessionStore()
const { updateRetention } = useSettings()
const online = useOnline()
const selected = ref(props.settings?.activity_retention_days ?? 0)
const shortening = ref<number | null>(null)
const failedValue = ref<number | null>(null)
const selectedDays = ref(props.settings?.activity_retention_days ?? 0)
const selectedGb = ref(props.settings?.activity_retention_max_gb ?? 0)
const shortening = ref<Pending | null>(null)
const failed = ref<Pending | null>(null)
const saveStatus = ref<SaveStatus>('idle')
let savedTimer: ReturnType<typeof setTimeout> | null = null
let pendingSaves = 0
@@ -50,11 +60,68 @@ let pendingSaves = 0
const offline = computed(() => !online.value)
const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton)
const savedDays = computed(() => props.settings?.activity_retention_days ?? 0)
const options = computed(() => retentionOptions())
const savedGb = computed(() => props.settings?.activity_retention_max_gb ?? 0)
const logSize = computed(() => props.settings?.activity_log_size_bytes ?? 0)
watch(savedDays, (days) => {
const controls = computed(() => [
{
key: 'days' as const,
labelId: 'retention-label',
label: t('Keep activity for'),
helper: helperCopy(selectedDays.value, selectedGb.value),
items: retentionOptions(),
value: selectedDays.value,
},
{
key: 'gb' as const,
labelId: 'retention-size-label',
label: t('Keep at most'),
helper: sizeHelperCopy(selectedGb.value, logSize.value),
items: sizeOptions(),
value: selectedGb.value,
},
])
const warningTitle = computed(() => {
const pending = shortening.value
if (pending === null) {
return ''
}
return pending.control === 'days'
? t('Events older than {0} days will be removed', [pending.value])
: t('Events over {0} GB will be removed', [pending.value])
})
const applyLabel = computed(() => {
const pending = shortening.value
if (pending === null) {
return ''
}
return pending.control === 'days'
? t('Apply {0} days', [pending.value])
: t('Apply {0} GB', [pending.value])
})
function select(control: Control, value: number) {
if (control === 'days') {
selectedDays.value = value
} else {
selectedGb.value = value
}
}
function savedValue(control: Control) {
return control === 'days' ? savedDays.value : savedGb.value
}
function settle() {
selectedDays.value = savedDays.value
selectedGb.value = savedGb.value
}
watch([savedDays, savedGb], () => {
if (shortening.value === null && pendingSaves === 0) {
selected.value = days
settle()
}
})
@@ -76,23 +143,34 @@ function finishSaving(succeeded: boolean) {
}, SAVED_VISIBLE_MS)
}
async function save(days: number) {
function patchFor({ control, value }: Pending): RetentionPatch {
return control === 'days'
? { activity_retention_days: value }
: { activity_retention_max_gb: value }
}
async function save(pending: Pending) {
pendingSaves += 1
clearSavedTimer()
failedValue.value = null
selected.value = days
failed.value = null
select(pending.control, pending.value)
saveStatus.value = 'saving'
let succeeded = false
try {
const updated = await updateRetention(days)
const updated = await updateRetention(patchFor(pending))
emit('saved', updated)
selected.value = updated.activity_retention_days
select(
pending.control,
pending.control === 'days'
? updated.activity_retention_days
: updated.activity_retention_max_gb,
)
succeeded = true
} catch (error) {
selected.value = savedDays.value
select(pending.control, savedValue(pending.control))
const status = error instanceof ApiError ? error.status : 0
if (status !== HTTP_UNAUTHORIZED) {
failedValue.value = days
failed.value = pending
}
if (status === HTTP_FORBIDDEN) {
session.refresh()
@@ -105,40 +183,49 @@ async function save(days: number) {
}
}
function choose(value: number) {
function isReduction({ control, value }: Pending) {
return control === 'days' ? isShorter(value, savedDays.value) : isSmaller(value, savedGb.value)
}
function choose(control: Control, value: number) {
if (offline.value) {
return
}
const previous = shortening.value
shortening.value = null
failedValue.value = null
if (value === savedDays.value) {
selected.value = value
failed.value = null
if (previous !== null && previous.control !== control) {
select(previous.control, savedValue(previous.control))
}
const pending: Pending = { control, value }
if (value === savedValue(control)) {
select(control, value)
return
}
if (isShorter(value, savedDays.value)) {
selected.value = value
shortening.value = value
if (isReduction(pending)) {
select(control, value)
shortening.value = pending
return
}
save(value)
save(pending)
}
function apply() {
const days = shortening.value
const pending = shortening.value
shortening.value = null
if (days !== null) {
save(days)
if (pending !== null) {
save(pending)
}
}
function cancel() {
shortening.value = null
selected.value = savedDays.value
settle()
}
function retry() {
if (failedValue.value !== null) {
save(failedValue.value)
if (failed.value !== null) {
save(failed.value)
}
}
@@ -158,7 +245,11 @@ onBeforeUnmount(clearSavedTimer)
{{ t('Retention') }}
</h2>
<p class="text-pretty text-muted">
{{ t('How long activity is kept. Only owners can change it.') }}
{{
t(
'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.',
)
}}
</p>
</div>
<span
@@ -218,7 +309,7 @@ onBeforeUnmount(clearSavedTimer)
<template v-else-if="loadState === 'ready' && settings">
<UAlert
v-if="failedValue !== null"
v-if="failed !== null"
role="alert"
color="error"
variant="soft"
@@ -233,7 +324,7 @@ onBeforeUnmount(clearSavedTimer)
<UButton
color="neutral"
variant="outline"
:class="OUTLINE_BUTTON"
:class="[OUTLINE_BUTTON, 'w-full justify-center']"
@click="retry"
>
{{ t('Try again') }}
@@ -241,31 +332,41 @@ onBeforeUnmount(clearSavedTimer)
</template>
</UAlert>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4">
<div class="flex min-w-0 flex-col gap-0.5">
<span id="retention-label" class="font-medium text-highlighted">
{{ t('Keep activity for') }}
</span>
<span class="text-xs text-muted">{{ helperCopy(selected) }}</span>
</div>
<USelectMenu
:model-value="selected"
:items="options"
value-key="value"
:search-input="false"
:disabled="offline"
aria-labelledby="retention-label"
color="neutral"
variant="outline"
class="w-full md:w-40"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 md:h-8 md:text-sm',
trailingIcon: 'size-4 text-muted',
content: 'min-w-48',
}"
@update:model-value="choose($event as number)"
<template v-for="(control, index) in controls" :key="control.key">
<USeparator
v-if="index > 0"
:label="t('or')"
:ui="{ label: 'text-xs/5 font-medium text-muted' }"
/>
</div>
<div
class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span :id="control.labelId" class="font-medium text-highlighted">
{{ control.label }}
</span>
<span class="text-xs/5 text-muted">{{ control.helper }}</span>
</div>
<USelectMenu
:model-value="control.value"
:items="control.items"
value-key="value"
:search-input="false"
:disabled="offline"
:aria-labelledby="control.labelId"
:data-testid="`retention-select-${control.key}`"
color="neutral"
variant="outline"
class="w-full md:w-40"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-lg ring-0 md:h-10 md:text-base',
trailingIcon: 'size-4 text-muted',
content: 'min-w-48',
}"
@update:model-value="choose(control.key, $event as number)"
/>
</div>
</template>
<UAlert
v-if="shortening !== null"
@@ -275,7 +376,7 @@ onBeforeUnmount(clearSavedTimer)
orientation="vertical"
:close="false"
icon="i-lucide-triangle-alert"
:title="t('Events older than {0} days will be removed', [shortening])"
:title="warningTitle"
:description="shorteningDescription(settings)"
:ui="{ actions: 'mt-2' }"
>
@@ -287,7 +388,7 @@ onBeforeUnmount(clearSavedTimer)
:class="OUTLINE_BUTTON"
@click="apply"
>
{{ t('Apply {0} days', [shortening]) }}
{{ applyLabel }}
</UButton>
<UButton
color="neutral"
@@ -310,7 +411,7 @@ onBeforeUnmount(clearSavedTimer)
:title="t('Activity logs are append-only')"
:description="
t(
'Events can\'t be edited or deleted from the app. Only the retention period removes them.',
'Events can\'t be edited or deleted from the app. Only the retention limits remove them.',
)
"
/>
@@ -324,7 +425,7 @@ onBeforeUnmount(clearSavedTimer)
</div>
<div class="flex flex-wrap gap-x-1">
<dt class="after:content-[':']">{{ t('Next cleanup') }}</dt>
<dd data-testid="retention-next" class="text-highlighted">
<dd data-testid="retention-next">
{{ nextCleanupLine(settings) }}
</dd>
</div>
+9 -2
View File
@@ -3,11 +3,18 @@ import { useApi } from '@/composables/useApi'
export interface WorkspaceSettings {
version: string
activity_retention_days: number
activity_retention_max_gb: number
activity_log_size_bytes: number
activity_last_cleanup_at: string | null
activity_last_cleanup_removed: number
activity_next_cleanup_at: string | null
}
export interface RetentionPatch {
activity_retention_days?: number
activity_retention_max_gb?: number
}
const SETTINGS_PATH = '/v1/settings/'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
@@ -18,11 +25,11 @@ export function useSettings() {
return request<WorkspaceSettings>(SETTINGS_PATH)
}
function updateRetention(days: number): Promise<WorkspaceSettings> {
function updateRetention(patch: RetentionPatch): Promise<WorkspaceSettings> {
return request<WorkspaceSettings>(SETTINGS_PATH, {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify({ activity_retention_days: days }),
body: JSON.stringify(patch),
})
}
@@ -322,8 +322,10 @@ describe('Response samples use the real domain JSON keys', () => {
expect(keysOf(parsed(find('GET /v1/settings')))).toEqual([
'activity_last_cleanup_at',
'activity_last_cleanup_removed',
'activity_log_size_bytes',
'activity_next_cleanup_at',
'activity_retention_days',
'activity_retention_max_gb',
'version',
])
})
+2
View File
@@ -722,6 +722,8 @@ export const API_GROUPS: ApiGroup[] = [
json({
version: 'v3.0.0-beta',
activity_retention_days: 90,
activity_retention_max_gb: 5,
activity_log_size_bytes: 3435973837,
activity_last_cleanup_at: '2026-09-10T03:00:00Z',
activity_last_cleanup_removed: 12,
activity_next_cleanup_at: '2026-09-11T03:00:00Z',
+22 -4
View File
@@ -1155,6 +1155,10 @@ export const messages = {
'Restarted to apply the update {0}': 'Redémarrage pour appliquer la mise à jour {0}',
'Changed the activity retention from {0} to {1}':
"Durée de conservation de l'activité modifiée : {0} → {1}",
'Changed the activity size limit from {0} to {1}':
"Limite de taille de l'activité modifiée : {0} → {1}",
'Changed the activity retention from {0} to {1} and the size limit from {2} to {3}':
"Durée de conservation de l'activité modifiée : {0} → {1}, et limite de taille : {2} → {3}",
'Removed {0} old events from the activity log':
"{0} anciens événements supprimés du journal d'activité",
'Removed 1 old event from the activity log':
@@ -1193,8 +1197,8 @@ export const messages = {
'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.',
'Your actions will appear here.': 'Vos actions apparaîtront ici.',
Retention: 'Conservation',
'How long activity is kept. Only owners can change it.':
'Durée de conservation de l’activité. Seul le propriétaire peut la modifier.',
'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.':
'Durée de conservation de l’activité et volume de journal à conserver. La première limite atteinte supprime les événements les plus anciens. Seul le propriétaire peut la modifier.',
'Keep activity for': 'Conserver l’activité pendant',
'90 days': '90 jours',
'365 days': '365 jours',
@@ -1203,8 +1207,8 @@ export const messages = {
'Nothing is removed automatically. The log grows over time.':
'Rien n’est supprimé automatiquement. Le journal grossit avec le temps.',
'Activity logs are append-only': 'Le journal d’activité est en ajout seul',
"Events can't be edited or deleted from the app. Only the retention period removes them.":
'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seule la durée de conservation les supprime.',
"Events can't be edited or deleted from the app. Only the retention limits remove them.":
'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seules les limites de conservation les suppriment.',
'Last cleanup': 'Dernier nettoyage',
'Next cleanup': 'Prochain nettoyage',
'Not scheduled': 'Non planifié',
@@ -1217,6 +1221,20 @@ export const messages = {
"Older events will be removed at the next cleanup. It can't be undone.":
'Les anciens événements seront supprimés au prochain nettoyage. Cette action est irréversible.',
'Apply {0} days': 'Appliquer {0} jours',
'Keep at most': 'Conserver au maximum',
or: 'ou',
'No size limit': 'Aucune limite de taille',
'{0} GB': '{0} Go',
'Less than 0.1 GB': 'Moins de 0,1 Go',
'Events older than this are removed.': 'Les événements plus anciens sont supprimés.',
'No age limit. Only the size limit applies.':
'Aucune limite d’âge. Seule la limite de taille s’applique.',
'Optional. Logs can grow without a size cap.':
'Facultatif. Le journal peut grossir sans limite de taille.',
'When logs pass {0} GB, the oldest events are removed. Currently {1}.':
'Au-delà de {0} Go, les événements les plus anciens sont supprimés. Actuellement {1}.',
'Events over {0} GB will be removed': 'Les événements au-delà de {0} Go seront supprimés',
'Apply {0} GB': 'Appliquer {0} Go',
"Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation',
'Your previous setting is still in place.': 'Votre réglage précédent est conservé.',
} as const
@@ -3,8 +3,11 @@ import { initializeLocale, setLocale } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { recentWhenLabel } from '../activityTime'
import {
formatLogSize,
helperCopy,
isShorter,
isSmaller,
sizeHelperCopy,
lastCleanupLine,
nextCleanupLine,
shorteningDescription,
@@ -20,6 +23,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, 0),
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3, 0),
@@ -86,3 +91,43 @@ describe('retention lines', () => {
)
})
})
describe('retention size limit', () => {
it('ranks 1 < 5 < 10 < 25 < 50 < No size limit', () => {
expect(isSmaller(1, 5)).toBe(true)
expect(isSmaller(5, 0)).toBe(true)
expect(isSmaller(0, 50)).toBe(false)
expect(isSmaller(5, 5)).toBe(false)
expect(isSmaller(10, 50)).toBe(true)
})
it.each([
[0, 'Less than 0.1 GB'],
[53687091, 'Less than 0.1 GB'],
[107374183, '0.1 GB'],
[1073741824, '1.0 GB'],
[3435973837, '3.2 GB'],
[5368709120, '5.0 GB'],
])('formats %s bytes as %s on a 1024^3 base', (bytes, shown) => {
expect(formatLogSize(bytes)).toBe(shown)
})
it('words the helpers for each combination', () => {
expect(helperCopy(90, 5)).toBe('Events older than this are removed.')
expect(helperCopy(0, 5)).toBe('No age limit. Only the size limit applies.')
expect(helperCopy(0, 0)).toBe('Nothing is removed automatically. The log grows over time.')
expect(sizeHelperCopy(0, 99)).toBe('Optional. Logs can grow without a size cap.')
expect(sizeHelperCopy(5, 3435973837)).toBe(
'When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB.',
)
})
it('words the helpers in French with Go and a decimal comma', () => {
setLocale('fr')
expect(sizeHelperCopy(5, 3435973837)).toBe(
'Au-delà de 5 Go, les événements les plus anciens sont supprimés. Actuellement 3,2 Go.',
)
expect(formatLogSize(0)).toBe('Moins de 0,1 Go')
})
})
@@ -101,6 +101,26 @@ describe('Sentences: English and French come from the same code and params', ()
'Changed the activity retention from 90 days to Forever',
"Durée de conservation de l'activité modifiée : 90 jours → Toujours",
],
[
'system.activity_retention_changed size only',
{
action: 'system.activity_retention_changed',
target: null,
params: { max_gb_from: 0, max_gb_to: 5 },
},
'Changed the activity size limit from No size limit to 5 GB',
"Limite de taille de l'activité modifiée : Aucune limite de taille → 5 Go",
],
[
'system.activity_retention_changed age and size',
{
action: 'system.activity_retention_changed',
target: null,
params: { from: 90, to: 30, max_gb_from: 10, max_gb_to: 0 },
},
'Changed the activity retention from 90 days to 30 days and the size limit from 10 GB to No size limit',
"Durée de conservation de l'activité modifiée : 90 jours → 30 jours, et limite de taille : 10 Go → Aucune limite de taille",
],
[
'system.restart_requested update',
{
+49 -4
View File
@@ -1,11 +1,16 @@
import { t } from '@/i18n'
import { formatNumber, t } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { localDayTime } from '@/lib/activityTime'
export const FOREVER_DAYS = 0
export const NO_SIZE_LIMIT = 0
export const SAVED_VISIBLE_MS = 2000
const BYTES_PER_GB = 1024 ** 3
const MIN_SHOWN_GB = 0.1
const RANKED_PERIODS = [30, 90, 365, FOREVER_DAYS]
const RANKED_SIZES = [1, 5, 10, 25, 50, NO_SIZE_LIMIT]
export function retentionOptions() {
return RANKED_PERIODS.map((value) => ({ value, label: periodLabel(value) }))
@@ -32,12 +37,52 @@ export function isShorter(candidate: number, saved: number) {
return periodRank(candidate) < periodRank(saved)
}
export function helperCopy(days: number) {
return days === FOREVER_DAYS
? t('Nothing is removed automatically. The log grows over time.')
export function sizeOptions() {
return RANKED_SIZES.map((value) => ({ value, label: sizeLabel(value) }))
}
export function sizeLabel(gb: number) {
return gb === NO_SIZE_LIMIT ? t('No size limit') : t('{0} GB', [gb])
}
export function sizeRank(gb: number) {
return RANKED_SIZES.indexOf(gb)
}
export function isSmaller(candidate: number, saved: number) {
return sizeRank(candidate) < sizeRank(saved)
}
export function formatLogSize(bytes: number) {
const gb = bytes / BYTES_PER_GB
if (gb < MIN_SHOWN_GB) {
return t('Less than 0.1 GB')
}
const figure = formatNumber(gb, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
return t('{0} GB', [figure])
}
export function helperCopy(days: number, gb = NO_SIZE_LIMIT) {
const sized = gb !== NO_SIZE_LIMIT
if (days === FOREVER_DAYS) {
return sized
? t('No age limit. Only the size limit applies.')
: t('Nothing is removed automatically. The log grows over time.')
}
return sized
? t('Events older than this are removed.')
: t('Older events are removed automatically.')
}
export function sizeHelperCopy(gb: number, bytes: number) {
return gb === NO_SIZE_LIMIT
? t('Optional. Logs can grow without a size cap.')
: t('When logs pass {0} GB, the oldest events are removed. Currently {1}.', [
gb,
formatLogSize(bytes),
])
}
export function lastCleanupLine(settings: WorkspaceSettings) {
if (!settings.activity_last_cleanup_at) {
return t('Never')
+29 -1
View File
@@ -1,6 +1,7 @@
import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
import { sizeLabel } from '@/lib/activityRetention'
export interface SentenceSegment {
text: string
@@ -139,9 +140,36 @@ function retentionDays(value: unknown) {
return days === 0 ? t('Forever') : t('{0} days', [formatNumber(days)])
}
function changedAge(params: Record<string, unknown>) {
return 'from' in params || 'to' in params
}
function changedSize(params: Record<string, unknown>) {
return 'max_gb_from' in params || 'max_gb_to' in params
}
function retentionTemplate(params: Record<string, unknown>): MessageKey {
if (changedSize(params) && changedAge(params)) {
return 'Changed the activity retention from {0} to {1} and the size limit from {2} to {3}'
}
return changedSize(params)
? 'Changed the activity size limit from {0} to {1}'
: 'Changed the activity retention from {0} to {1}'
}
function retentionValues(params: Record<string, unknown>) {
const sizes = [sizeLabel(count(params.max_gb_from)), sizeLabel(count(params.max_gb_to))]
if (!changedAge(params)) return sizes
const ages = [retentionDays(params.from), retentionDays(params.to)]
return changedSize(params) ? [...ages, ...sizes] : ages
}
function resolveTemplate(item: ActivityItem): MessageKey | null {
const template = CATALOGUE[item.action]
if (!template) return null
if (item.action === 'system.activity_retention_changed') {
return retentionTemplate(item.params)
}
if (item.action === 'system.restart_requested' && item.outcome === 'success') {
return item.params.kind === 'update'
? 'Restarted to apply the update {0}'
@@ -156,7 +184,7 @@ function slotValues(item: ActivityItem, template: MessageKey) {
return [purgeSummary(item.params)]
}
if (item.action === 'system.activity_retention_changed') {
return [retentionDays(item.params.from), retentionDays(item.params.to)]
return retentionValues(item.params)
}
if (item.action === 'system.activity_cleanup') {
return [formatNumber(count(item.params.removed))]
@@ -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()
})
})