feat: image variants cache panel

This commit is contained in:
fsylvain committed 2026-10-08 21:13:32 +02:00
1 parent 951fef18ba
commit d768668014
53 files changed
+5809 -46

No files matched your search

+457
View File
@@ -0,0 +1,457 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import CacheByVariantCard from '@/components/variants/CacheByVariantCard.vue'
import CachePurgeBanner from '@/components/variants/CachePurgeBanner.vue'
import CacheSkeleton from '@/components/variants/CacheSkeleton.vue'
import CacheStatTile from '@/components/variants/CacheStatTile.vue'
import CacheTopImagesCard from '@/components/variants/CacheTopImagesCard.vue'
import TypeToConfirmDialog from '@/components/variants/TypeToConfirmDialog.vue'
import VariantsForbidden from '@/components/variants/VariantsForbidden.vue'
import VariantsTabs from '@/components/variants/VariantsTabs.vue'
import type {
CacheImageStat,
CachePurgeRequest,
CachePurgeResult,
} from '@/composables/useVariantCache'
import { useVariantToasts } from '@/composables/useVariantToasts'
import { cacheSize } from '@/lib/variants/cacheFormat'
import { isHandledByApiClient, isStatus } from '@/lib/variants/variantErrors'
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { useSessionStore } from '@/stores/session'
import { useVariantCacheStore } from '@/stores/variantCache'
const DESKTOP_QUERY = '(min-width: 40rem)'
const SKELETON_DELAY_MS = 150
const NOT_FOUND = 404
const CONFIRM_WORD = 'purge'
const router = useRouter()
const session = useSessionStore()
const store = useVariantCacheStore()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const { announceFailure, announceSuccess } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const showSkeleton = ref(false)
const purgeUnusedButton = ref<{ $el: HTMLElement } | null>(null)
const purgeAllButton = ref<{ $el: HTMLElement } | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const forbidden = computed(
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
)
const stats = computed(() => store.stats)
const ready = computed(() => store.loadState === 'ready' && stats.value !== null)
const dimmed = computed(() => store.isPurging)
const purgeUnusedDisabled = computed(
() => !ready.value || store.isPurging || (stats.value?.unused.files ?? 0) === 0,
)
const purgeAllDisabled = computed(
() => !ready.value || store.isPurging || (stats.value?.generated_files ?? 0) === 0,
)
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadStats() {
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
const loading = store.load()
try {
await loading
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
watch(
[sessionReady, canManage],
([isReady, isAdmin]) => {
if (isReady && isAdmin && !requested) {
requested = true
loadStats()
}
},
{ immediate: true },
)
onBeforeUnmount(clearSkeletonTimer)
async function restoreFocus(element: HTMLElement | null | undefined) {
await nextTick()
if (element?.isConnected) {
element.focus()
}
}
function freed(result: CachePurgeResult) {
const size = formatVariantSize(result.purged_bytes)
return result.purged_files === 1
? t('Purged 1 file, freed {0}', [size])
: t('Purged {0} files, freed {1}', [formatNumber(result.purged_files), size])
}
function purgedForImage(result: CachePurgeResult, filename: string) {
const size = formatVariantSize(result.purged_bytes)
return result.purged_files === 1
? t('Purged 1 file ({0}) for {1}', [size, filename])
: t('Purged {0} files ({1}) for {2}', [formatNumber(result.purged_files), size, filename])
}
function announceIncomplete(
request: CachePurgeRequest,
result: CachePurgeResult,
filename: string,
) {
toast.add(() => ({
color: 'error' as const,
icon: 'i-lucide-circle-x',
title: t("Purge didn't finish"),
description: t('{0} of {1} files were removed. Try again.', [
formatNumber(result.purged_files),
formatNumber(result.purged_files + result.failed_files),
]),
actions: [
{
label: t('Try again'),
icon: 'i-lucide-rotate-cw',
color: 'neutral' as const,
variant: 'outline' as const,
onClick: () => {
void runPurge(request, filename)
},
},
],
}))
}
function announceResult(request: CachePurgeRequest, result: CachePurgeResult, filename: string) {
if (result.failed_files > 0) {
announceIncomplete(request, result, filename)
return
}
announceSuccess(() =>
request.scope === 'image' ? purgedForImage(result, filename) : freed(result),
)
}
async function runPurge(request: CachePurgeRequest, filename = '') {
let result: CachePurgeResult
try {
result = await store.purge(request)
} catch (error) {
if (isHandledByApiClient(error)) {
return
}
announceFailure(error)
if (isStatus(error, NOT_FOUND)) {
await store.refresh()
}
return
}
announceResult(request, result, filename)
await store.refresh()
}
async function onPurgeUnused() {
const current = stats.value
if (!current) {
return
}
const { files, bytes, days } = current.unused
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Purge unused variants?')
},
get description() {
return files === 1
? t(
"This removes 1 file ({0}) from enabled variants that haven't been requested for {1} days. Files are generated again on request.",
[formatVariantSize(bytes), days],
)
: t(
"This removes {0} files ({1}) from enabled variants that haven't been requested for {2} days. Files are generated again on request.",
[formatNumber(files), formatVariantSize(bytes), days],
)
},
get confirmLabel() {
return files === 1
? t('Purge 1 file')
: t('Purge {0} files', [formatNumber(files)])
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Purging…')
},
tone: 'neutral',
action: async () => undefined,
},
})
.open()
restoreFocus(purgeUnusedButton.value?.$el)
if (confirmed) {
await runPurge({ scope: 'unused' })
}
}
async function onPurgeAll() {
const current = stats.value
if (!current) {
return
}
const files = current.generated_files
const size = formatVariantSize(current.disk_used)
const confirmed = await overlay
.create(TypeToConfirmDialog, {
props: {
get title() {
return t('Purge all variants?')
},
get description() {
return files === 1
? t(
"This removes 1 generated file ({0}) for every variant. Originals aren't touched. Files are generated again on request.",
[size],
)
: t(
"This removes {0} generated files ({1}) for every variant. Originals aren't touched. Files are generated again on request.",
[formatNumber(files), size],
)
},
get fieldLabel() {
return t('Type purge to confirm')
},
word: CONFIRM_WORD,
get confirmLabel() {
return t('Purge all variants')
},
get cancelLabel() {
return t('Cancel')
},
},
})
.open()
restoreFocus(purgeAllButton.value?.$el)
if (confirmed) {
await runPurge({ scope: 'all' })
}
}
function onPurgeImage(item: CacheImageStat) {
return runPurge({ scope: 'image', image_id: item.id }, item.filename)
}
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Cache')" :no-current-nav="forbidden">
<template #breadcrumb>
<UBreadcrumb
:items="[
{
get label() {
return t('Settings')
},
to: '/settings',
},
{
get label() {
return t('Media')
},
},
{
get label() {
return t('Cache')
},
},
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<VariantsForbidden v-if="forbidden" @home="router.push('/home')" />
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<h1 class="text-xl/8 font-semibold text-highlighted sm:text-stat">
{{ t('Cache') }}
</h1>
<p class="max-w-155 text-pretty text-muted">
{{
t(
'Generated variant files. Purged files are generated again on the next request.',
)
}}
</p>
</div>
<div class="flex shrink-0 flex-col gap-2 sm:flex-row">
<UButton
ref="purgeUnusedButton"
data-testid="purge-unused"
color="neutral"
variant="outline"
icon="i-lucide-clock"
:disabled="purgeUnusedDisabled"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
@click="onPurgeUnused"
>
{{ t('Purge unused') }}
</UButton>
<UButton
ref="purgeAllButton"
data-testid="purge-all"
color="neutral"
variant="outline"
icon="i-lucide-trash-2"
:disabled="purgeAllDisabled"
class="h-11 justify-center border border-error bg-default px-3 text-sm text-error ring-0 sm:h-8"
@click="onPurgeAll"
>
{{ t('Purge all variants') }}
</UButton>
</div>
</div>
<VariantsTabs active="cache" />
<CacheSkeleton
v-if="store.loadState === 'loading' && showSkeleton"
:desktop="isDesktop"
/>
<UAlert
v-else-if="store.loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load cache stats')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 sm:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadStats"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<template v-else-if="ready && stats">
<CachePurgeBanner v-if="store.running" :files="store.running.files" />
<div
class="grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-4"
:class="dimmed ? 'opacity-60' : ''"
>
<CacheStatTile
icon="i-lucide-files"
:label="t('Generated files')"
:value="formatNumber(stats.generated_files)"
:sub="
store.isEmpty
? t('Nothing cached')
: t(
stats.variants_defined === 1
? 'Across 1 variant'
: 'Across {0} variants',
[formatNumber(stats.variants_defined)],
)
"
/>
<CacheStatTile
icon="i-lucide-hard-drive"
:label="t('Disk used')"
:value="cacheSize(stats.disk_used)"
:sub="store.isEmpty ? t('Nothing cached') : t('Originals not included')"
/>
<CacheStatTile
icon="i-lucide-layers"
:label="t('Variants defined')"
:value="formatNumber(stats.variants_defined)"
:sub="
t('{0} enabled, {1} disabled', [
formatNumber(stats.variants_enabled),
formatNumber(stats.variants_disabled),
])
"
/>
<CacheStatTile
icon="i-lucide-ban"
:label="t('Rejected requests')"
:value="formatNumber(stats.rejected_requests)"
:sub="t('Last {0} days', [formatNumber(stats.rejected_window_days)])"
/>
</div>
<UEmpty
v-if="store.isEmpty"
variant="naked"
icon="i-lucide-database"
:title="t('Nothing generated yet')"
:description="
t(
'Variants are generated on the first request with ?v=<name>, or from an image\'s Variants tab.',
)
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-95 text-pretty text-muted',
}"
/>
<div
v-else
class="grid grid-cols-1 items-start gap-4 sm:grid-cols-[460px_minmax(0,1fr)]"
:class="dimmed ? 'opacity-60' : ''"
>
<CacheByVariantCard :items="stats.by_variant" />
<CacheTopImagesCard
:items="stats.top_images"
:desktop="isDesktop"
:locked="store.isPurging"
:is-purging="store.isPurgingImage"
@purge="onPurgeImage"
/>
</div>
</template>
</div>
</DashboardShell>
</template>