Files
RenewCMS/web/src/components/RetentionCard.vue
T

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>