feat: setup logs retention by max file size
This commit is contained in:
1 parent
70c8492f4f
commit
f914eae3c5
44 files changed
+2316
-161
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user