436 lines
11 KiB
Vue
436 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { useOnline } from '@vueuse/core'
|
|
import { ApiError } from '@/composables/useApi'
|
|
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'
|
|
|
|
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
|
const SKELETON_BAR = 'rounded-none bg-accented'
|
|
const OUTLINE_BUTTON =
|
|
'h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
|
|
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
|
|
loadState: 'loading' | 'ready' | 'error'
|
|
showSkeleton: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
saved: [settings: WorkspaceSettings]
|
|
reload: []
|
|
}>()
|
|
|
|
const session = useSessionStore()
|
|
const { updateRetention } = useSettings()
|
|
const online = useOnline()
|
|
|
|
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
|
|
|
|
const offline = computed(() => !online.value)
|
|
const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton)
|
|
const savedDays = computed(() => props.settings?.activity_retention_days ?? 0)
|
|
const savedGb = computed(() => props.settings?.activity_retention_max_gb ?? 0)
|
|
const logSize = computed(() => props.settings?.activity_log_size_bytes ?? 0)
|
|
|
|
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) {
|
|
settle()
|
|
}
|
|
})
|
|
|
|
function clearSavedTimer() {
|
|
if (savedTimer !== null) {
|
|
clearTimeout(savedTimer)
|
|
savedTimer = null
|
|
}
|
|
}
|
|
|
|
function finishSaving(succeeded: boolean) {
|
|
if (!succeeded) {
|
|
saveStatus.value = 'idle'
|
|
return
|
|
}
|
|
saveStatus.value = 'saved'
|
|
savedTimer = setTimeout(() => {
|
|
saveStatus.value = 'idle'
|
|
}, SAVED_VISIBLE_MS)
|
|
}
|
|
|
|
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()
|
|
failed.value = null
|
|
select(pending.control, pending.value)
|
|
saveStatus.value = 'saving'
|
|
let succeeded = false
|
|
try {
|
|
const updated = await updateRetention(patchFor(pending))
|
|
emit('saved', updated)
|
|
select(
|
|
pending.control,
|
|
pending.control === 'days'
|
|
? updated.activity_retention_days
|
|
: updated.activity_retention_max_gb,
|
|
)
|
|
succeeded = true
|
|
} catch (error) {
|
|
select(pending.control, savedValue(pending.control))
|
|
const status = error instanceof ApiError ? error.status : 0
|
|
if (status !== HTTP_UNAUTHORIZED) {
|
|
failed.value = pending
|
|
}
|
|
if (status === HTTP_FORBIDDEN) {
|
|
session.refresh()
|
|
}
|
|
} finally {
|
|
pendingSaves -= 1
|
|
if (pendingSaves === 0) {
|
|
finishSaving(succeeded)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
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 (isReduction(pending)) {
|
|
select(control, value)
|
|
shortening.value = pending
|
|
return
|
|
}
|
|
save(pending)
|
|
}
|
|
|
|
function apply() {
|
|
const pending = shortening.value
|
|
shortening.value = null
|
|
if (pending !== null) {
|
|
save(pending)
|
|
}
|
|
}
|
|
|
|
function cancel() {
|
|
shortening.value = null
|
|
settle()
|
|
}
|
|
|
|
function retry() {
|
|
if (failed.value !== null) {
|
|
save(failed.value)
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(clearSavedTimer)
|
|
</script>
|
|
|
|
<template>
|
|
<section
|
|
aria-labelledby="retention-h"
|
|
data-testid="retention-card"
|
|
:aria-busy="skeletonVisible ? 'true' : undefined"
|
|
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
<h2 id="retention-h" class="text-[0.9375rem]/6 font-semibold text-highlighted">
|
|
{{ t('Retention') }}
|
|
</h2>
|
|
<p class="text-pretty text-muted">
|
|
{{
|
|
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
|
|
aria-live="polite"
|
|
data-testid="retention-save-status"
|
|
class="flex h-6 shrink-0 items-center"
|
|
>
|
|
<span
|
|
v-if="saveStatus === 'saving'"
|
|
class="inline-flex items-center gap-1.5 text-xs text-muted"
|
|
>
|
|
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
|
{{ t('Saving…') }}
|
|
</span>
|
|
<span
|
|
v-else-if="saveStatus === 'saved'"
|
|
class="inline-flex items-center gap-1.5 text-xs text-success"
|
|
>
|
|
<UIcon name="i-lucide-check" class="size-3.5" />
|
|
{{ t('Saved') }}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<template v-if="skeletonVisible">
|
|
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-30']" />
|
|
<USkeleton :class="[SKELETON_BAR, 'h-8 w-full max-w-65']" />
|
|
<USkeleton :class="[SKELETON_BAR, 'h-3 w-3/5']" />
|
|
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-9/10']" />
|
|
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-7/10']" />
|
|
</template>
|
|
|
|
<UAlert
|
|
v-else-if="loadState === 'error'"
|
|
role="alert"
|
|
color="error"
|
|
variant="soft"
|
|
orientation="vertical"
|
|
:close="false"
|
|
icon="i-lucide-circle-x"
|
|
:description="t(UNREACHABLE_COPY)"
|
|
:ui="{ actions: 'mt-2' }"
|
|
>
|
|
<template #actions>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-rotate-cw"
|
|
:class="OUTLINE_BUTTON"
|
|
:ui="{ leadingIcon: 'size-3.5' }"
|
|
@click="emit('reload')"
|
|
>
|
|
{{ t('Try again') }}
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<template v-else-if="loadState === 'ready' && settings">
|
|
<UAlert
|
|
v-if="failed !== null"
|
|
role="alert"
|
|
color="error"
|
|
variant="soft"
|
|
orientation="vertical"
|
|
:close="false"
|
|
icon="i-lucide-circle-x"
|
|
:title="t('Couldn\'t save the retention period')"
|
|
:description="t('Your previous setting is still in place.')"
|
|
:ui="{ actions: 'mt-2' }"
|
|
>
|
|
<template #actions>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
:class="[OUTLINE_BUTTON, 'w-full justify-center']"
|
|
@click="retry"
|
|
>
|
|
{{ t('Try again') }}
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<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
|
|
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"
|
|
role="alert"
|
|
color="warning"
|
|
variant="soft"
|
|
orientation="vertical"
|
|
:close="false"
|
|
icon="i-lucide-triangle-alert"
|
|
:title="warningTitle"
|
|
:description="shorteningDescription(settings)"
|
|
:ui="{ actions: 'mt-2' }"
|
|
>
|
|
<template #actions>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
:disabled="offline"
|
|
:class="OUTLINE_BUTTON"
|
|
@click="apply"
|
|
>
|
|
{{ applyLabel }}
|
|
</UButton>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
:disabled="offline"
|
|
:class="OUTLINE_BUTTON"
|
|
@click="cancel"
|
|
>
|
|
{{ t('Cancel') }}
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<UAlert
|
|
role="status"
|
|
color="info"
|
|
variant="soft"
|
|
:close="false"
|
|
icon="i-lucide-lock"
|
|
:title="t('Activity logs are append-only')"
|
|
:description="
|
|
t(
|
|
'Events can\'t be edited or deleted from the app. Only the retention limits remove them.',
|
|
)
|
|
"
|
|
/>
|
|
|
|
<dl class="flex flex-col gap-1 text-toned">
|
|
<div class="flex flex-wrap gap-x-1">
|
|
<dt class="after:content-[':']">{{ t('Last cleanup') }}</dt>
|
|
<dd data-testid="retention-last">
|
|
{{ lastCleanupLine(settings) }}
|
|
</dd>
|
|
</div>
|
|
<div class="flex flex-wrap gap-x-1">
|
|
<dt class="after:content-[':']">{{ t('Next cleanup') }}</dt>
|
|
<dd data-testid="retention-next">
|
|
{{ nextCleanupLine(settings) }}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</template>
|
|
</section>
|
|
</template>
|