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>
|
||||
|
||||
Reference in new issue
Block a user