feat: image variants cache panel
This commit is contained in:
1 parent
951fef18ba
commit
d768668014
53 files changed
+5809
-46
No files matched your search
@@ -78,8 +78,8 @@ const settingsChildren = computed(() => [
|
||||
},
|
||||
{
|
||||
label: t('Cache'),
|
||||
to: '/settings/media/cache',
|
||||
icon: 'i-lucide-database',
|
||||
disabled: true,
|
||||
active: !props.noCurrentNav && route.path === '/settings/media/cache',
|
||||
},
|
||||
])
|
||||
|
||||
@@ -396,10 +396,9 @@ describe('DashboardShell Settings expands to the Media subpages', () => {
|
||||
const variants = nav.find('a[href="/settings/media/variants"]')
|
||||
expect(variants.text()).toContain('Variants')
|
||||
expect(variants.attributes('aria-current')).toBeUndefined()
|
||||
const cache = nav
|
||||
.findAll('[aria-disabled="true"], [disabled]')
|
||||
.find((el) => el.text().includes('Cache'))
|
||||
expect(cache).toBeDefined()
|
||||
const cache = nav.find('a[href="/settings/media/cache"]')
|
||||
expect(cache.text()).toContain('Cache')
|
||||
expect(cache.attributes('aria-current')).toBeUndefined()
|
||||
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBe('page')
|
||||
},
|
||||
)
|
||||
@@ -414,6 +413,14 @@ describe('DashboardShell Settings expands to the Media subpages', () => {
|
||||
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks Cache current and Settings not current on the cache page', async () => {
|
||||
const wrapper = await mountShell('/settings/media/cache', { role: 'admin' })
|
||||
|
||||
const nav = wrapper.find('nav[aria-label="Main"]')
|
||||
expect(nav.find('a[href="/settings/media/cache"]').attributes('aria-current')).toBe('page')
|
||||
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows no Media subpages without an owner or admin role', async () => {
|
||||
const wrapper = await mountShell('/settings', { role: 'user' })
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { CacheVariantStat } from '@/composables/useVariantCache'
|
||||
import { barPercent, filesAndSize } from '@/lib/variants/cacheFormat'
|
||||
|
||||
const props = defineProps<{ items: CacheVariantStat[] }>()
|
||||
|
||||
const largest = computed(() => Math.max(0, ...props.items.map((item) => item.bytes)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
data-testid="cache-by-variant"
|
||||
:ui="{
|
||||
root: 'min-w-0 rounded-none border border-default bg-default ring-0 divide-y-0',
|
||||
header: 'flex min-h-11 items-center border-b border-default px-4 py-0 sm:px-4 sm:py-0',
|
||||
body: 'my-2 flex flex-col gap-4 px-4 py-2 sm:my-2 sm:gap-3 sm:px-4 sm:py-2',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-highlighted">{{ t('By variant') }}</h2>
|
||||
</template>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.name"
|
||||
data-testid="cache-variant-row"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-mono font-medium text-highlighted">{{ item.name }}</span>
|
||||
<UBadge
|
||||
v-if="!item.enabled"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-circle-off"
|
||||
class="border border-accented bg-transparent text-toned ring-0"
|
||||
:ui="{ base: 'h-4.5 gap-1 px-1.5 py-0 text-xs', leadingIcon: 'size-3' }"
|
||||
>
|
||||
{{ t('Disabled') }}
|
||||
</UBadge>
|
||||
<span class="flex-1" />
|
||||
<span class="text-xs text-toned">{{ filesAndSize(item.files, item.bytes) }}</span>
|
||||
</div>
|
||||
<UProgress
|
||||
:model-value="barPercent(item.bytes, largest)"
|
||||
:max="100"
|
||||
:aria-label="item.name"
|
||||
:ui="{
|
||||
base: 'h-2 rounded-none bg-accented',
|
||||
indicator: item.enabled
|
||||
? 'rounded-none bg-primary'
|
||||
: 'rounded-none bg-(--ui-border-accented)',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
|
||||
defineProps<{ files: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
data-testid="cache-purge-banner"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-loader-circle"
|
||||
:close="false"
|
||||
:title="files === 1 ? t('Purging 1 file') : t('Purging {0} files', [formatNumber(files)])"
|
||||
:ui="{
|
||||
root: 'items-center rounded-none px-3.5 py-3',
|
||||
icon: 'size-4 animate-spin',
|
||||
title: 'font-semibold',
|
||||
}"
|
||||
>
|
||||
<template #description>
|
||||
<UProgress
|
||||
:model-value="null"
|
||||
:max="100"
|
||||
:ui="{
|
||||
base: 'h-1.5 rounded-none bg-accented',
|
||||
indicator: 'rounded-none bg-info',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import CacheStatTile from '@/components/variants/CacheStatTile.vue'
|
||||
|
||||
defineProps<{ desktop: boolean }>()
|
||||
|
||||
const VARIANT_ROWS = 5
|
||||
const IMAGE_ROWS = 6
|
||||
const BAR = 'rounded-none bg-accented'
|
||||
const CARD_UI = {
|
||||
root: 'min-w-0 rounded-none border border-default bg-default ring-0',
|
||||
body: 'flex flex-col gap-5 p-4 sm:p-4',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :aria-label="t('Loading cache')" aria-busy="true" class="flex flex-col gap-3 sm:gap-4">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-4">
|
||||
<CacheStatTile icon="i-lucide-files" :label="t('Generated files')" loading />
|
||||
<CacheStatTile icon="i-lucide-hard-drive" :label="t('Disk used')" loading />
|
||||
<CacheStatTile icon="i-lucide-layers" :label="t('Variants defined')" loading />
|
||||
<CacheStatTile icon="i-lucide-ban" :label="t('Rejected requests')" loading />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[460px_minmax(0,1fr)]">
|
||||
<UCard :ui="CARD_UI">
|
||||
<div v-for="row in VARIANT_ROWS" :key="row" class="flex flex-col gap-2">
|
||||
<USkeleton class="h-3.25 w-[50%]" :class="BAR" />
|
||||
<USkeleton class="h-2 w-full" :class="BAR" />
|
||||
</div>
|
||||
</UCard>
|
||||
<UCard v-if="desktop" :ui="CARD_UI">
|
||||
<USkeleton
|
||||
v-for="row in IMAGE_ROWS"
|
||||
:key="row"
|
||||
class="h-3.25 w-[90%]"
|
||||
:class="BAR"
|
||||
/>
|
||||
</UCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
value?: string
|
||||
sub?: string
|
||||
loading?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
data-testid="cache-tile"
|
||||
:ui="{
|
||||
root: 'min-w-0 rounded-none border border-default bg-default ring-0',
|
||||
body: 'flex min-w-0 flex-col gap-1 p-3 sm:p-4',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-sm text-muted">
|
||||
<UIcon :name="icon" class="size-3.5 shrink-0" />
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<USkeleton v-if="loading" class="h-7 w-[60%] rounded-none bg-accented" />
|
||||
<span v-else class="text-stat-sm/9 font-semibold text-highlighted sm:text-2xl/9">
|
||||
{{ value }}
|
||||
</span>
|
||||
<USkeleton v-if="loading" class="h-2.75 w-[40%] rounded-none bg-accented" />
|
||||
<span v-else class="text-xs text-muted">{{ sub }}</span>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { CacheImageStat } from '@/composables/useVariantCache'
|
||||
import { variantsCount } from '@/lib/variants/cacheFormat'
|
||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||
|
||||
defineProps<{
|
||||
items: CacheImageStat[]
|
||||
desktop: boolean
|
||||
locked: boolean
|
||||
isPurging: (id: number) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ purge: [item: CacheImageStat] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
data-testid="cache-top-images"
|
||||
:ui="{
|
||||
root: 'min-w-0 rounded-none border border-default bg-default ring-0 divide-y-0',
|
||||
header: 'flex min-h-11 items-center border-b border-default px-4 py-0 sm:px-4 sm:py-0',
|
||||
body: 'p-0 sm:p-0',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-highlighted">
|
||||
{{ t('Top 10 images by variant disk usage') }}
|
||||
</h2>
|
||||
</template>
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
data-testid="cache-image-row"
|
||||
class="flex min-h-15 items-center gap-3 px-3 sm:min-h-10 sm:px-4"
|
||||
:class="index > 0 ? 'border-t border-muted' : ''"
|
||||
>
|
||||
<span class="w-5 font-mono text-xs text-muted">{{ index + 1 }}</span>
|
||||
<MediaThumb
|
||||
:item="{ url: item.url, content_type: '', sha256: '' }"
|
||||
size="sm"
|
||||
class="h-6 w-8 border border-default"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col sm:flex-row sm:gap-3">
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-highlighted">
|
||||
{{ item.filename }}
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-xs text-muted sm:w-37.5">
|
||||
{{ variantsCount(item.files)
|
||||
}}{{ desktop ? '' : ` · ${formatVariantSize(item.bytes)}` }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="desktop" class="w-15 text-right text-toned">
|
||||
{{ formatVariantSize(item.bytes) }}
|
||||
</span>
|
||||
<UTooltip v-if="!desktop" :text="t('Purge {0}', [item.filename])">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-eraser"
|
||||
:aria-label="t('Purge {0}', [item.filename])"
|
||||
:loading="isPurging(item.id)"
|
||||
:disabled="locked || isPurging(item.id)"
|
||||
class="size-11 shrink-0 justify-center border border-accented bg-default text-highlighted ring-0"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('purge', item)"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UButton
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-eraser"
|
||||
:aria-label="t('Purge {0}', [item.filename])"
|
||||
:loading="isPurging(item.id)"
|
||||
:disabled="locked || isPurging(item.id)"
|
||||
class="h-6.5 shrink-0 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('purge', item)"
|
||||
>
|
||||
{{ t('Purge') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
fieldLabel: string
|
||||
word: string
|
||||
confirmLabel: string
|
||||
cancelLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
const typed = ref('')
|
||||
const field = ref<{ inputRef: HTMLInputElement | null } | null>(null)
|
||||
let settled = false
|
||||
|
||||
const matches = computed(() => typed.value === props.word)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve))
|
||||
field.value?.inputRef?.focus()
|
||||
})
|
||||
|
||||
function settle(confirmed: boolean) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
open.value = false
|
||||
emit('close', confirmed)
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) {
|
||||
settle(false)
|
||||
}
|
||||
})
|
||||
|
||||
function onConfirm() {
|
||||
if (matches.value) {
|
||||
settle(true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:close="false"
|
||||
:content="{ role: 'alertdialog' }"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
}"
|
||||
@update:open="open = $event"
|
||||
>
|
||||
<template #body>
|
||||
<UFormField :label="fieldLabel" name="confirm-word">
|
||||
<UInput
|
||||
ref="field"
|
||||
v-model="typed"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
class="w-full"
|
||||
:ui="{ base: 'font-mono' }"
|
||||
@keydown.enter.prevent="onConfirm"
|
||||
/>
|
||||
</UFormField>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="h-11 justify-center text-sm text-highlighted sm:h-8 sm:w-auto sm:px-3"
|
||||
@click="settle(false)"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="error"
|
||||
block
|
||||
:disabled="!matches"
|
||||
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -1,9 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
withDefaults(defineProps<{ active?: 'variants' | 'cache' }>(), { active: 'variants' })
|
||||
|
||||
const TAB = 'h-9 gap-1.5 rounded-none border-b-2 px-0 py-0 text-sm hover:bg-transparent max-sm:h-11'
|
||||
const ACTIVE = 'border-primary font-medium text-highlighted'
|
||||
const INACTIVE = 'border-transparent font-normal text-toned aria-disabled:opacity-100'
|
||||
const INACTIVE = 'border-transparent font-normal text-toned'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'variants' as const,
|
||||
to: '/settings/media/variants',
|
||||
icon: 'i-lucide-layers',
|
||||
label: () => t('Variants'),
|
||||
},
|
||||
{
|
||||
key: 'cache' as const,
|
||||
to: '/settings/media/cache',
|
||||
icon: 'i-lucide-database',
|
||||
label: () => t('Cache'),
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,27 +30,20 @@ const INACTIVE = 'border-transparent font-normal text-toned aria-disabled:opacit
|
||||
class="flex gap-5 border-b border-default"
|
||||
>
|
||||
<UButton
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
icon="i-lucide-layers"
|
||||
:to="tab.to"
|
||||
:active="false"
|
||||
:aria-selected="active === tab.key ? 'true' : 'false'"
|
||||
:aria-current="active === tab.key ? 'page' : undefined"
|
||||
:icon="tab.icon"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:class="[TAB, ACTIVE, '-mb-px']"
|
||||
:class="[TAB, active === tab.key ? ACTIVE : INACTIVE, '-mb-px']"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
>
|
||||
{{ t('Variants') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-disabled="true"
|
||||
icon="i-lucide-database"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:class="[TAB, INACTIVE, '-mb-px']"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
>
|
||||
{{ t('Cache') }}
|
||||
{{ tab.label() }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
|
||||
export type CachePurgeScope = 'all' | 'unused' | 'variant' | 'image'
|
||||
|
||||
export interface CacheVariantStat {
|
||||
name: string
|
||||
enabled: boolean
|
||||
files: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface CacheImageStat {
|
||||
id: number
|
||||
filename: string
|
||||
url: string
|
||||
files: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
generated_files: number
|
||||
disk_used: number
|
||||
variants_defined: number
|
||||
variants_enabled: number
|
||||
variants_disabled: number
|
||||
rejected_requests: number
|
||||
rejected_window_days: number
|
||||
unused: { files: number; bytes: number; days: number }
|
||||
by_variant: CacheVariantStat[]
|
||||
top_images: CacheImageStat[]
|
||||
}
|
||||
|
||||
export interface CachePurgeRequest {
|
||||
scope: CachePurgeScope
|
||||
variant?: string
|
||||
image_id?: number
|
||||
}
|
||||
|
||||
export interface CachePurgeResult {
|
||||
scope: CachePurgeScope
|
||||
purged_files: number
|
||||
purged_bytes: number
|
||||
failed_files: number
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||
const CACHE_PATH = '/v1/image-variants/cache'
|
||||
|
||||
export function useVariantCache() {
|
||||
const { request } = useApi()
|
||||
|
||||
function getStats(): Promise<CacheStats> {
|
||||
return request<CacheStats>(CACHE_PATH)
|
||||
}
|
||||
|
||||
function purge(input: CachePurgeRequest): Promise<CachePurgeResult> {
|
||||
return request<CachePurgeResult>(`${CACHE_PATH}/purge`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
return { getStats, purge }
|
||||
}
|
||||
@@ -1422,6 +1422,62 @@ export const messages = {
|
||||
'Les variantes ne sont pas générées pour ce format.',
|
||||
"Variant '{0}' no longer exists.": 'La variante « {0} » n’existe plus.',
|
||||
"Variant '{0}' is disabled.": 'La variante « {0} » est désactivée.',
|
||||
'Generated variant files. Purged files are generated again on the next request.':
|
||||
'Fichiers de variantes générés. Les fichiers purgés sont régénérés à la prochaine requête.',
|
||||
'Purge unused': 'Purger les inutilisés',
|
||||
'Purge all variants': 'Purger toutes les variantes',
|
||||
'Loading cache': 'Chargement du cache',
|
||||
'Generated files': 'Fichiers générés',
|
||||
'Disk used': 'Espace disque utilisé',
|
||||
'Variants defined': 'Variantes définies',
|
||||
'Rejected requests': 'Requêtes rejetées',
|
||||
'Across 1 variant': 'Sur 1 variante',
|
||||
'Across {0} variants': 'Sur {0} variantes',
|
||||
'Originals not included': 'Originaux non inclus',
|
||||
'Nothing cached': 'Rien en cache',
|
||||
'{0} enabled, {1} disabled': '{0} activées, {1} désactivées',
|
||||
'Last {0} days': '{0} derniers jours',
|
||||
'By variant': 'Par variante',
|
||||
'1 file': '1 fichier',
|
||||
'1 file · {0}': '1 fichier · {0}',
|
||||
'{0} files · {1}': '{0} fichiers · {1}',
|
||||
'Top 10 images by variant disk usage': 'Top 10 des images par espace disque de variantes',
|
||||
'Nothing generated yet': 'Rien n’a encore été généré',
|
||||
"Variants are generated on the first request with ?v=<name>, or from an image's Variants tab.":
|
||||
'Les variantes sont générées à la première requête avec ?v=<nom>, ou depuis l’onglet Variantes d’une image.',
|
||||
"Couldn't load cache stats": 'Impossible de charger les statistiques du cache',
|
||||
'Purge all variants?': 'Purger toutes les variantes ?',
|
||||
"This removes 1 generated file ({0}) for every variant. Originals aren't touched. Files are generated again on request.":
|
||||
'Cela supprime 1 fichier généré ({0}) pour chaque variante. Les originaux ne sont pas touchés. Les fichiers sont régénérés à la demande.',
|
||||
"This removes {0} generated files ({1}) for every variant. Originals aren't touched. Files are generated again on request.":
|
||||
'Cela supprime {0} fichiers générés ({1}) pour chaque variante. Les originaux ne sont pas touchés. Les fichiers sont régénérés à la demande.',
|
||||
'Type purge to confirm': 'Saisissez purge pour confirmer',
|
||||
'Purge unused variants?': 'Purger les variantes inutilisées ?',
|
||||
"This removes 1 file ({0}) from enabled variants that haven't been requested for {1} days. Files are generated again on request.":
|
||||
'Cela supprime 1 fichier ({0}) des variantes activées qui n’ont pas été demandées depuis {1} jours. Les fichiers sont régénérés à la demande.',
|
||||
"This removes {0} files ({1}) from enabled variants that haven't been requested for {2} days. Files are generated again on request.":
|
||||
'Cela supprime {0} fichiers ({1}) des variantes activées qui n’ont pas été demandées depuis {2} jours. Les fichiers sont régénérés à la demande.',
|
||||
'Purging 1 file': 'Purge de 1 fichier',
|
||||
'Purging {0} files': 'Purge de {0} fichiers',
|
||||
'Purged 1 file, freed {0}': '1 fichier purgé, {0} libérés',
|
||||
'Purged {0} files, freed {1}': '{0} fichiers purgés, {1} libérés',
|
||||
"Purge didn't finish": 'La purge n’est pas terminée',
|
||||
'{0} of {1} files were removed. Try again.':
|
||||
'{0} fichiers sur {1} ont été supprimés. Réessayez.',
|
||||
'Purged 1 file ({0}) for {1}': '1 fichier purgé ({0}) pour {1}',
|
||||
'Purged {0} files ({1}) for {2}': '{0} fichiers purgés ({1}) pour {2}',
|
||||
'Purged all variants': 'A purgé toutes les variantes',
|
||||
'Failed to purge all variants': 'Échec de la purge de toutes les variantes',
|
||||
'Purged unused variants': 'A purgé les variantes inutilisées',
|
||||
'Failed to purge unused variants': 'Échec de la purge des variantes inutilisées',
|
||||
'Purged the cache of variant “{0}”': 'A purgé le cache de la variante « {0} »',
|
||||
'Failed to purge the cache of variant “{0}”':
|
||||
'Échec de la purge du cache de la variante « {0} »',
|
||||
'Removed 1 file ({0}) not requested for {1} days':
|
||||
'1 fichier supprimé ({0}) non demandé depuis {1} jours',
|
||||
'Removed {0} files ({1}) not requested for {2} days':
|
||||
'{0} fichiers supprimés ({1}) non demandés depuis {2} jours',
|
||||
'{0} failed': '{0} en échec',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { detailLine, sentenceText } from '../activitySentence'
|
||||
|
||||
function purgeItem(overrides: Partial<ActivityItem>): ActivityItem {
|
||||
return {
|
||||
id: 1,
|
||||
created_at: '2026-10-03T12:00:00Z',
|
||||
action: 'media.variant_purge_all',
|
||||
category: 'media',
|
||||
actor: { type: 'user', id: 3, name: 'Marta Alves' },
|
||||
target: null,
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
security: false,
|
||||
source: 'web',
|
||||
ip: null,
|
||||
user_agent: null,
|
||||
params: {},
|
||||
changes: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const hero = { type: 'image_variant', id: 'hero', name: 'hero', exists: true }
|
||||
const wall = { type: 'image', id: '1', name: 'north-wall.jpg', exists: true }
|
||||
|
||||
const rows: [string, Partial<ActivityItem>, string, string | null][] = [
|
||||
[
|
||||
'media.variant_purge_all success',
|
||||
{ params: { purged_files: 1284, purged_bytes: 222298112 } },
|
||||
'Purged all variants',
|
||||
'Removed 1,284 files (212 MB)',
|
||||
],
|
||||
['media.variant_purge_all failed', { outcome: 'failed' }, 'Failed to purge all variants', null],
|
||||
[
|
||||
'media.variant_purge_unused success',
|
||||
{
|
||||
action: 'media.variant_purge_unused',
|
||||
params: { purged_files: 312, purged_bytes: 42991616, older_than_days: 30 },
|
||||
},
|
||||
'Purged unused variants',
|
||||
'Removed 312 files (41 MB) not requested for 30 days',
|
||||
],
|
||||
[
|
||||
'media.variant_purge_unused failed',
|
||||
{ action: 'media.variant_purge_unused', outcome: 'failed' },
|
||||
'Failed to purge unused variants',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'media.variant_purge success',
|
||||
{
|
||||
action: 'media.variant_purge',
|
||||
target: hero,
|
||||
params: { purged_files: 244, purged_bytes: 88080384 },
|
||||
},
|
||||
'Purged the cache of variant “hero”',
|
||||
'Removed 244 files (84 MB)',
|
||||
],
|
||||
[
|
||||
'media.variant_purge failed',
|
||||
{ action: 'media.variant_purge', target: hero, outcome: 'failed' },
|
||||
'Failed to purge the cache of variant “hero”',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'media.variant_purge_image success',
|
||||
{
|
||||
action: 'media.variant_purge_image',
|
||||
target: wall,
|
||||
params: { purged_files: 6, purged_bytes: 6082765 },
|
||||
},
|
||||
'Purged the variants of north-wall.jpg',
|
||||
'Removed 6 files (5.8 MB)',
|
||||
],
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('The Activity screen words each cache purge scope', () => {
|
||||
it.each(rows)('%s', (_, overrides, sentence, detail) => {
|
||||
const subject = purgeItem(overrides)
|
||||
|
||||
expect(sentenceText(subject)).toBe(sentence)
|
||||
expect(detailLine(subject)).toBe(detail)
|
||||
})
|
||||
|
||||
it('A partial purge adds the failed count to the detail line', () => {
|
||||
const subject = purgeItem({
|
||||
params: { purged_files: 640, purged_bytes: 111149056, failed_files: 644 },
|
||||
})
|
||||
|
||||
expect(detailLine(subject)).toBe('Removed 640 files (106 MB) · 644 failed')
|
||||
})
|
||||
|
||||
it('The singular reads 1 file', () => {
|
||||
const subject = purgeItem({ params: { purged_files: 1, purged_bytes: 2048 } })
|
||||
|
||||
expect(detailLine(subject)).toBe('Removed 1 file (2 KB)')
|
||||
})
|
||||
})
|
||||
@@ -231,6 +231,9 @@ describe('The catalogue is complete in both languages', () => {
|
||||
'media.merge',
|
||||
'media.purge',
|
||||
'media.variant_generate_all',
|
||||
'media.variant_purge',
|
||||
'media.variant_purge_all',
|
||||
'media.variant_purge_unused',
|
||||
'media.variant_purge_image',
|
||||
'system.media_purge',
|
||||
'user.register',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MessageKey } from '@/i18n/messages'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { sizeLabel } from '@/lib/activityRetention'
|
||||
import { formatSize } from '@/lib/mediaFormat'
|
||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||
import { causeText } from '@/lib/variants/variantCauses'
|
||||
|
||||
export interface SentenceSegment {
|
||||
@@ -62,6 +63,18 @@ const CATALOGUE: Record<string, Template> = {
|
||||
success: 'Generated all variants of {0}',
|
||||
failed: 'Failed to generate the variants of {0}',
|
||||
},
|
||||
'media.variant_purge': {
|
||||
success: 'Purged the cache of variant “{0}”',
|
||||
failed: 'Failed to purge the cache of variant “{0}”',
|
||||
},
|
||||
'media.variant_purge_all': {
|
||||
success: 'Purged all variants',
|
||||
failed: 'Failed to purge all variants',
|
||||
},
|
||||
'media.variant_purge_unused': {
|
||||
success: 'Purged unused variants',
|
||||
failed: 'Failed to purge unused variants',
|
||||
},
|
||||
'media.variant_purge_image': {
|
||||
success: 'Purged the variants of {0}',
|
||||
failed: 'Failed to purge the variants of {0}',
|
||||
@@ -262,7 +275,42 @@ function purgeImageDetail(params: Record<string, unknown>) {
|
||||
: t('Removed {0} files ({1})', [formatNumber(files), size])
|
||||
}
|
||||
|
||||
const DEFAULT_UNUSED_DAYS = 30
|
||||
const CACHE_PURGE_ACTIONS = [
|
||||
'media.variant_purge',
|
||||
'media.variant_purge_all',
|
||||
'media.variant_purge_unused',
|
||||
]
|
||||
|
||||
function removedFiles(files: number, size: string, days: number | null) {
|
||||
if (days === null) {
|
||||
return files === 1
|
||||
? t('Removed 1 file ({0})', [size])
|
||||
: t('Removed {0} files ({1})', [formatNumber(files), size])
|
||||
}
|
||||
return files === 1
|
||||
? t('Removed 1 file ({0}) not requested for {1} days', [size, days])
|
||||
: t('Removed {0} files ({1}) not requested for {2} days', [formatNumber(files), size, days])
|
||||
}
|
||||
|
||||
function cachePurgeDetail(item: ActivityItem) {
|
||||
if (item.outcome === 'failed') return null
|
||||
const params = item.params
|
||||
const unused = item.action === 'media.variant_purge_unused'
|
||||
const days = unused ? count(params.older_than_days) || DEFAULT_UNUSED_DAYS : null
|
||||
const removed = removedFiles(
|
||||
count(params.purged_files),
|
||||
formatVariantSize(count(params.purged_bytes)),
|
||||
days,
|
||||
)
|
||||
const failed = count(params.failed_files)
|
||||
return failed > 0 ? `${removed} · ${t('{0} failed', [formatNumber(failed)])}` : removed
|
||||
}
|
||||
|
||||
export function detailLine(item: ActivityItem): string | null {
|
||||
if (CACHE_PURGE_ACTIONS.includes(item.action)) {
|
||||
return cachePurgeDetail(item)
|
||||
}
|
||||
if (item.action === 'media.variant_generate_all') {
|
||||
return generateAllDetail(item.params)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||
|
||||
const PERCENT = 100
|
||||
|
||||
export function cacheSize(bytes: number) {
|
||||
return bytes === 0 ? t('{0} MB', [0]) : formatVariantSize(bytes)
|
||||
}
|
||||
|
||||
export function filesCount(count: number) {
|
||||
return count === 1 ? t('1 file') : t('{0} files', [formatNumber(count)])
|
||||
}
|
||||
|
||||
export function filesAndSize(count: number, bytes: number) {
|
||||
return count === 1
|
||||
? t('1 file · {0}', [formatVariantSize(bytes)])
|
||||
: t('{0} files · {1}', [formatNumber(count), formatVariantSize(bytes)])
|
||||
}
|
||||
|
||||
export function variantsCount(count: number) {
|
||||
return count === 1 ? t('1 variant') : t('{0} variants', [formatNumber(count)])
|
||||
}
|
||||
|
||||
export function barPercent(bytes: number, largest: number) {
|
||||
return largest === 0 ? 0 : Math.round((bytes / largest) * PERCENT)
|
||||
}
|
||||
@@ -111,6 +111,11 @@ const router = createRouter({
|
||||
component: () => import('@/views/VariantsPage.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
},
|
||||
{
|
||||
path: '/settings/media/cache',
|
||||
component: () => import('@/views/CachePage.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('@/views/NotFoundView.vue'),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
useVariantCache,
|
||||
type CachePurgeRequest,
|
||||
type CachePurgeResult,
|
||||
type CacheStats,
|
||||
} from '@/composables/useVariantCache'
|
||||
|
||||
export type CacheLoadState = 'loading' | 'ready' | 'error' | 'forbidden'
|
||||
|
||||
export interface RunningPurge {
|
||||
scope: CachePurgeRequest['scope']
|
||||
files: number
|
||||
}
|
||||
|
||||
const FORBIDDEN_STATUS = 403
|
||||
const UNAUTHORIZED_STATUS = 401
|
||||
|
||||
export const useVariantCacheStore = defineStore('variantCache', () => {
|
||||
const api = useVariantCache()
|
||||
const stats = ref<CacheStats | null>(null)
|
||||
const loadState = ref<CacheLoadState>('loading')
|
||||
const running = ref<RunningPurge | null>(null)
|
||||
const purgingImages = ref<number[]>([])
|
||||
|
||||
const isEmpty = computed(
|
||||
() => loadState.value === 'ready' && stats.value?.generated_files === 0,
|
||||
)
|
||||
const isPurging = computed(() => running.value !== null)
|
||||
|
||||
function isPurgingImage(id: number) {
|
||||
return purgingImages.value.includes(id)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loadState.value = 'loading'
|
||||
try {
|
||||
stats.value = await api.getStats()
|
||||
loadState.value = 'ready'
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === FORBIDDEN_STATUS) {
|
||||
loadState.value = 'forbidden'
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === UNAUTHORIZED_STATUS) {
|
||||
return
|
||||
}
|
||||
loadState.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
stats.value = await api.getStats()
|
||||
loadState.value = 'ready'
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function filesFor(request: CachePurgeRequest) {
|
||||
if (request.scope === 'unused') {
|
||||
return stats.value?.unused.files ?? 0
|
||||
}
|
||||
return stats.value?.generated_files ?? 0
|
||||
}
|
||||
|
||||
async function purge(request: CachePurgeRequest): Promise<CachePurgeResult> {
|
||||
if (request.scope === 'image' && request.image_id !== undefined) {
|
||||
const id = request.image_id
|
||||
purgingImages.value = [...purgingImages.value, id]
|
||||
try {
|
||||
return await api.purge(request)
|
||||
} finally {
|
||||
purgingImages.value = purgingImages.value.filter((entry) => entry !== id)
|
||||
}
|
||||
}
|
||||
running.value = { scope: request.scope, files: filesFor(request) }
|
||||
try {
|
||||
return await api.purge(request)
|
||||
} finally {
|
||||
running.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return { stats, loadState, running, isEmpty, isPurging, isPurgingImage, load, refresh, purge }
|
||||
})
|
||||
@@ -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>
|
||||
@@ -0,0 +1,787 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import type { CacheStats } from '@/composables/useVariantCache'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import CachePage from '../CachePage.vue'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const STATS = 'GET /v1/image-variants/cache'
|
||||
const PURGE = 'POST /v1/image-variants/cache/purge'
|
||||
const MB = 1048576
|
||||
|
||||
type Handler = (body?: Record<string, unknown>) => Promise<unknown>
|
||||
|
||||
const TOP_IMAGES: [string, number, number][] = [
|
||||
['north-wall.jpg', 6, 6082765],
|
||||
['lime-plaster.jpg', 6, 5138022],
|
||||
['kitchen-after.webp', 5, 4613734],
|
||||
['kitchen-before.webp', 5, 4089446],
|
||||
['floorboards.jpg', 5, 3774873],
|
||||
['IMG_4471.jpg', 4, 3250585],
|
||||
['stone-wall-detail.jpg', 4, 2936012],
|
||||
['bathroom-tiles.png', 4, 2726297],
|
||||
['roof-lantern.jpg', 3, 2411724],
|
||||
['chimney-stack.jpg', 3, 2202009],
|
||||
]
|
||||
|
||||
function stats(overrides: Partial<CacheStats> = {}): CacheStats {
|
||||
return {
|
||||
generated_files: 1284,
|
||||
disk_used: 222298112,
|
||||
variants_defined: 6,
|
||||
variants_enabled: 5,
|
||||
variants_disabled: 1,
|
||||
rejected_requests: 37,
|
||||
rejected_window_days: 7,
|
||||
unused: { files: 312, bytes: 42991616, days: 30 },
|
||||
by_variant: [
|
||||
{ name: 'hero', enabled: true, files: 244, bytes: 88080384 },
|
||||
{ name: 'card', enabled: true, files: 318, bytes: 60817408 },
|
||||
{ name: 'print', enabled: false, files: 74, bytes: 22020096 },
|
||||
{ name: 'thumb', enabled: true, files: 412, bytes: 22020096 },
|
||||
{ name: 'square', enabled: true, files: 140, bytes: 19922944 },
|
||||
{ name: 'og', enabled: true, files: 96, bytes: 9437184 },
|
||||
],
|
||||
top_images: TOP_IMAGES.map(([filename, files, bytes], index) => ({
|
||||
id: index + 1,
|
||||
filename,
|
||||
url: `/static/uploadedImages/${filename}`,
|
||||
files,
|
||||
bytes,
|
||||
})),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function emptyStats() {
|
||||
return stats({
|
||||
generated_files: 0,
|
||||
disk_used: 0,
|
||||
unused: { files: 0, bytes: 0, days: 30 },
|
||||
by_variant: [],
|
||||
top_images: [],
|
||||
})
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function setViewport(desktop: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: desktop,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
async function mountPage(
|
||||
options: { desktop?: boolean; role?: string | null; routes?: Record<string, Handler> } = {},
|
||||
) {
|
||||
const { desktop = true, role = 'owner', routes = {} } = options
|
||||
setViewport(desktop)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
const sessionRole = role ?? undefined
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
role: sessionRole as 'owner' | 'admin' | undefined,
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? 'GET'} ${path}`
|
||||
const handler = routes[key] ?? (key === STATS ? () => Promise.resolve(stats()) : null)
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected request: ${key}`)
|
||||
}
|
||||
return handler(
|
||||
init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : undefined,
|
||||
)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/settings/media/cache')
|
||||
const Host = defineComponent({ render: () => h(UApp, null, () => h(CachePage)) })
|
||||
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function calls(key: string) {
|
||||
return requestMock.mock.calls.filter(
|
||||
([path, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${path}` === key,
|
||||
)
|
||||
}
|
||||
|
||||
function bodyOf(key: string, index = 0) {
|
||||
return JSON.parse(String((calls(key)[index]![1] as RequestInit).body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}
|
||||
|
||||
function buttonIn(root: ParentNode, text: string) {
|
||||
return Array.from(root.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
return buttonIn(document.body, text)
|
||||
}
|
||||
|
||||
function buttonByLabel(label: string) {
|
||||
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
||||
}
|
||||
|
||||
async function click(element: HTMLElement | null | undefined) {
|
||||
element?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function textOf(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent?.trim()
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function alertDialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
||||
}
|
||||
|
||||
function tiles() {
|
||||
return Array.from(document.body.querySelectorAll('[data-testid="cache-tile"]')).map((tile) =>
|
||||
textOf(tile),
|
||||
)
|
||||
}
|
||||
|
||||
function toastCalls() {
|
||||
return toasts.add.mock.calls.map(([source]) => source as Record<string, unknown>)
|
||||
}
|
||||
|
||||
async function typeWord(value: string) {
|
||||
const input = alertDialog()!.querySelector<HTMLInputElement>('input')!
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function confirmButton() {
|
||||
return buttonIn(alertDialog()!, 'Purge all variants')!
|
||||
}
|
||||
|
||||
async function unmountPage() {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
vi.mocked(useApi).mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountPage()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Rule: Cache screen, access and header', () => {
|
||||
it.each([
|
||||
['no role', null],
|
||||
['role owner but the API answers 403', 'owner'],
|
||||
])(
|
||||
'A session whose role is not owner or admin gets the Forbidden state: %s',
|
||||
async (_, role) => {
|
||||
await mountPage({
|
||||
role,
|
||||
routes: role ? { [STATS]: () => Promise.reject(new ApiError(403)) } : {},
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain("You don't have access")
|
||||
expect(document.body.textContent).toContain(
|
||||
'Only admins can manage image variants. Ask an admin if you need one changed.',
|
||||
)
|
||||
expect(findButton('Back to Home')).toBeDefined()
|
||||
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
|
||||
expect(document.body.querySelector('h1:not([data-slot="title"])')).toBeNull()
|
||||
expect(calls(STATS)).toHaveLength(role ? 1 : 0)
|
||||
},
|
||||
)
|
||||
|
||||
it('The header and the tabs', async () => {
|
||||
await mountPage()
|
||||
|
||||
expect(document.body.querySelector('h1:not([data-slot="title"])')?.textContent).toBe(
|
||||
'Cache',
|
||||
)
|
||||
expect(document.body.textContent).toContain(
|
||||
'Generated variant files. Purged files are generated again on the next request.',
|
||||
)
|
||||
expect(findButton('Purge unused')).toBeDefined()
|
||||
expect(findButton('Purge all variants')).toBeDefined()
|
||||
const tabs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
|
||||
expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(['Variants', 'Cache'])
|
||||
expect(tabs[1]!.getAttribute('aria-selected')).toBe('true')
|
||||
expect(tabs[1]!.className).toContain('border-primary')
|
||||
expect(tabs[0]!.getAttribute('href')).toBe('/settings/media/variants')
|
||||
expect(calls(STATS)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('Activating Variants routes to the variants page', async () => {
|
||||
await mountPage()
|
||||
|
||||
await click(document.body.querySelector<HTMLElement>('[role="tab"]'))
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/settings/media/variants'),
|
||||
)
|
||||
})
|
||||
|
||||
it('The desktop breadcrumb reads Settings, Media, Cache', async () => {
|
||||
await mountPage()
|
||||
|
||||
const items = Array.from(
|
||||
document.body.querySelectorAll('nav[aria-label="breadcrumb"] li'),
|
||||
).map((item) => item.textContent?.trim())
|
||||
expect(items.filter(Boolean)).toEqual(['Settings', 'Media', 'Cache'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Cache screen, loaded', () => {
|
||||
it('The four stat tiles', async () => {
|
||||
await mountPage()
|
||||
|
||||
expect(tiles()).toEqual([
|
||||
'Generated files 1,284 Across 6 variants',
|
||||
'Disk used 212 MB Originals not included',
|
||||
'Variants defined 6 5 enabled, 1 disabled',
|
||||
'Rejected requests 37 Last 7 days',
|
||||
])
|
||||
})
|
||||
|
||||
it('The By variant list', async () => {
|
||||
await mountPage()
|
||||
|
||||
const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-variant-row"]'))
|
||||
expect(rows.map((row) => textOf(row))).toEqual([
|
||||
'hero 244 files · 84 MB',
|
||||
'card 318 files · 58 MB',
|
||||
'print Disabled 74 files · 21 MB',
|
||||
'thumb 412 files · 21 MB',
|
||||
'square 140 files · 19 MB',
|
||||
'og 96 files · 9 MB',
|
||||
])
|
||||
const widths = rows.map((row) =>
|
||||
row.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow'),
|
||||
)
|
||||
expect(widths).toEqual(['100', '69', '25', '25', '23', '11'])
|
||||
const printBar = rows[2]!.querySelector('[data-slot="indicator"]')!
|
||||
expect(printBar.className).toContain('border-accented')
|
||||
expect(rows[0]!.querySelector('[data-slot="indicator"]')!.className).toContain('bg-primary')
|
||||
expect(rows[0]!.querySelector('span.font-mono')!.className).toContain('font-medium')
|
||||
})
|
||||
|
||||
it('The Top 10 list on desktop', async () => {
|
||||
await mountPage()
|
||||
|
||||
const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-image-row"]'))
|
||||
expect(rows).toHaveLength(10)
|
||||
expect(textOf(rows[0])).toBe('1 north-wall.jpg 6 variants 5.8 MB Purge')
|
||||
expect(textOf(rows[9])).toBe('10 chimney-stack.jpg 3 variants 2.1 MB Purge')
|
||||
expect(rows[0]!.querySelector('[data-testid="media-thumb"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('The Top 10 list on mobile', async () => {
|
||||
await mountPage({ desktop: false })
|
||||
|
||||
const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-image-row"]'))
|
||||
expect(textOf(rows[0])).toBe('1 north-wall.jpg 6 variants · 5.8 MB')
|
||||
const purge = buttonByLabel('Purge north-wall.jpg')!
|
||||
expect(purge.textContent?.trim()).toBe('')
|
||||
expect(purge.className).toContain('size-11')
|
||||
})
|
||||
|
||||
it('The singular reads 1 file and 1 variant', async () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[STATS]: () =>
|
||||
Promise.resolve(
|
||||
stats({
|
||||
by_variant: [{ name: 'hero', enabled: true, files: 1, bytes: MB }],
|
||||
top_images: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'a.jpg',
|
||||
url: '/static/uploadedImages/a.jpg',
|
||||
files: 1,
|
||||
bytes: MB,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
expect(textOf(document.body.querySelector('[data-testid="cache-variant-row"]'))).toBe(
|
||||
'hero 1 file · 1 MB',
|
||||
)
|
||||
expect(textOf(document.body.querySelector('[data-testid="cache-image-row"]'))).toContain(
|
||||
'1 variant',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Cache screen, loading, empty and error', () => {
|
||||
it('Loading', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred<CacheStats>()
|
||||
await mountPage({ routes: { [STATS]: () => pending.promise } })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
const region = document.body.querySelector('[aria-busy="true"]')!
|
||||
expect(region.getAttribute('aria-label')).toBe('Loading cache')
|
||||
expect(region.querySelectorAll('[data-testid="cache-tile"]')).toHaveLength(4)
|
||||
expect(region.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
|
||||
pending.resolve(stats())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
it('A response at 100 ms never draws skeletons', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred<CacheStats>()
|
||||
await mountPage({ routes: { [STATS]: () => pending.promise } })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
pending.resolve(stats())
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(tiles()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('Empty cache', async () => {
|
||||
await mountPage({ routes: { [STATS]: () => Promise.resolve(emptyStats()) } })
|
||||
|
||||
expect(tiles()).toEqual([
|
||||
'Generated files 0 Nothing cached',
|
||||
'Disk used 0 MB Nothing cached',
|
||||
'Variants defined 6 5 enabled, 1 disabled',
|
||||
'Rejected requests 37 Last 7 days',
|
||||
])
|
||||
expect(document.body.textContent).toContain('Nothing generated yet')
|
||||
expect(document.body.textContent).toContain(
|
||||
"Variants are generated on the first request with ?v=<name>, or from an image's Variants tab.",
|
||||
)
|
||||
expect(findButton('Purge unused')!.disabled).toBe(true)
|
||||
expect(findButton('Purge all variants')!.disabled).toBe(true)
|
||||
expect(document.body.querySelector('[data-testid="cache-by-variant"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="cache-top-images"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network error', () => Promise.reject(new NetworkError())],
|
||||
])('Error loading: %s', async (_, failure) => {
|
||||
let attempt = 0
|
||||
await mountPage({
|
||||
routes: { [STATS]: () => (attempt++ === 0 ? failure() : Promise.resolve(stats())) },
|
||||
})
|
||||
|
||||
const alert = document.body.querySelector<HTMLElement>('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain("Couldn't load cache stats")
|
||||
expect(textOf(alert)).toContain('Check your connection and try again.')
|
||||
expect(alert.querySelector('[data-slot="close"]')).toBeNull()
|
||||
expect(tiles()).toHaveLength(0)
|
||||
|
||||
await click(buttonIn(alert, 'Try again'))
|
||||
|
||||
expect(calls(STATS)).toHaveLength(2)
|
||||
expect(tiles()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('Purge unused is disabled when nothing is unused', async () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[STATS]: () => Promise.resolve(stats({ unused: { files: 0, bytes: 0, days: 30 } })),
|
||||
},
|
||||
})
|
||||
|
||||
expect(findButton('Purge unused')!.disabled).toBe(true)
|
||||
expect(findButton('Purge all variants')!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Cache screen, purge all with a typed word', () => {
|
||||
it('The dialog opens with the button disabled', async () => {
|
||||
await mountPage()
|
||||
|
||||
await click(findButton('Purge all variants'))
|
||||
|
||||
const dialog = alertDialog()!
|
||||
expect(textOf(dialog)).toContain('Purge all variants?')
|
||||
expect(textOf(dialog)).toContain(
|
||||
"This removes 1,284 generated files (212 MB) for every variant. Originals aren't touched. Files are generated again on request.",
|
||||
)
|
||||
expect(textOf(dialog)).toContain('Type purge to confirm')
|
||||
expect(dialog.querySelector('input')!.className).toContain('font-mono')
|
||||
expect(buttonIn(dialog, 'Cancel')).toBeDefined()
|
||||
expect(confirmButton().disabled).toBe(true)
|
||||
expect(calls(PURGE)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pur', true],
|
||||
['Purge', true],
|
||||
['purge', false],
|
||||
['purged', true],
|
||||
])('The word unlocks the button: %s', async (typed, disabled) => {
|
||||
await mountPage()
|
||||
await click(findButton('Purge all variants'))
|
||||
|
||||
await typeWord(typed)
|
||||
|
||||
expect(confirmButton().disabled).toBe(disabled)
|
||||
})
|
||||
|
||||
it('Cancel sends nothing', async () => {
|
||||
await mountPage()
|
||||
await click(findButton('Purge all variants'))
|
||||
|
||||
await click(buttonIn(alertDialog()!, 'Cancel'))
|
||||
|
||||
await vi.waitFor(() => expect(alertDialog()).toBeNull())
|
||||
expect(calls(PURGE)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('Confirming sends the purge', async () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PURGE]: () =>
|
||||
Promise.resolve({
|
||||
scope: 'all',
|
||||
purged_files: 1284,
|
||||
purged_bytes: 222298112,
|
||||
failed_files: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
await click(findButton('Purge all variants'))
|
||||
await typeWord('purge')
|
||||
|
||||
await click(confirmButton())
|
||||
|
||||
await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(1))
|
||||
expect(bodyOf(PURGE)).toEqual({ scope: 'all' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Cache screen, purge unused', () => {
|
||||
it('The dialog states the count from the stats and confirming sends the purge', async () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PURGE]: () =>
|
||||
Promise.resolve({
|
||||
scope: 'unused',
|
||||
purged_files: 312,
|
||||
purged_bytes: 42991616,
|
||||
failed_files: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await click(findButton('Purge unused'))
|
||||
|
||||
const dialog = alertDialog()!
|
||||
expect(textOf(dialog)).toContain('Purge unused variants?')
|
||||
expect(textOf(dialog)).toContain(
|
||||
"This removes 312 files (41 MB) from enabled variants that haven't been requested for 30 days. Files are generated again on request.",
|
||||
)
|
||||
expect(dialog.querySelector('input')).toBeNull()
|
||||
expect(buttonIn(dialog, 'Cancel')).toBeDefined()
|
||||
|
||||
await click(buttonIn(dialog, 'Purge 312 files'))
|
||||
|
||||
await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(1))
|
||||
expect(bodyOf(PURGE)).toEqual({ scope: 'unused' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Cache screen, purging, done and failed', () => {
|
||||
async function startPurgeAll(routes: Record<string, Handler>) {
|
||||
await mountPage({ routes })
|
||||
await click(findButton('Purge all variants'))
|
||||
await typeWord('purge')
|
||||
await click(confirmButton())
|
||||
}
|
||||
|
||||
it('Purging', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await startPurgeAll({ [PURGE]: () => pending.promise })
|
||||
|
||||
const banner = document.body.querySelector('[data-testid="cache-purge-banner"]')!
|
||||
expect(textOf(banner)).toContain('Purging 1,284 files')
|
||||
expect(banner.getAttribute('aria-live')).toBe('polite')
|
||||
expect(banner.querySelector('[role="progressbar"]')).not.toBeNull()
|
||||
expect(findButton('Purge unused')!.disabled).toBe(true)
|
||||
expect(findButton('Purge all variants')!.disabled).toBe(true)
|
||||
expect(buttonByLabel('Purge north-wall.jpg')!.disabled).toBe(true)
|
||||
expect(document.body.querySelector('.opacity-60')).not.toBeNull()
|
||||
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('Purge done after purge all', async () => {
|
||||
let reads = 0
|
||||
await startPurgeAll({
|
||||
[STATS]: () => Promise.resolve(reads++ === 0 ? stats() : emptyStats()),
|
||||
[PURGE]: () =>
|
||||
Promise.resolve({
|
||||
scope: 'all',
|
||||
purged_files: 1284,
|
||||
purged_bytes: 222298112,
|
||||
failed_files: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
expect(toastCalls()[0]).toMatchObject({
|
||||
color: 'success',
|
||||
title: 'Purged 1,284 files, freed 212 MB',
|
||||
})
|
||||
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('Nothing generated yet'))
|
||||
})
|
||||
|
||||
it('Purge done after purge unused', async () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PURGE]: () =>
|
||||
Promise.resolve({
|
||||
scope: 'unused',
|
||||
purged_files: 312,
|
||||
purged_bytes: 42991616,
|
||||
failed_files: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
await click(findButton('Purge unused'))
|
||||
await click(buttonIn(alertDialog()!, 'Purge 312 files'))
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
expect(toastCalls()[0]).toMatchObject({ title: 'Purged 312 files, freed 41 MB' })
|
||||
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('Purge failed offers Try again without asking for the word again', async () => {
|
||||
await startPurgeAll({
|
||||
[PURGE]: () =>
|
||||
Promise.resolve({
|
||||
scope: 'all',
|
||||
purged_files: 640,
|
||||
purged_bytes: 111149056,
|
||||
failed_files: 644,
|
||||
}),
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
const toast = toastCalls()[0]!
|
||||
expect(toast).toMatchObject({
|
||||
color: 'error',
|
||||
title: "Purge didn't finish",
|
||||
description: '640 of 1,284 files were removed. Try again.',
|
||||
})
|
||||
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
||||
const [action] = toast.actions as { label: string; onClick: () => void }[]
|
||||
expect(action!.label).toBe('Try again')
|
||||
|
||||
action!.onClick()
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(2))
|
||||
expect(bodyOf(PURGE, 1)).toEqual({ scope: 'all' })
|
||||
expect(alertDialog()).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a 400',
|
||||
new ApiError(400),
|
||||
'Check the request',
|
||||
"A value in the request isn't valid. Reload the page and try again.",
|
||||
],
|
||||
[
|
||||
'a 403',
|
||||
new ApiError(403),
|
||||
"You don't have access",
|
||||
'Only admins can manage image variants.',
|
||||
],
|
||||
[
|
||||
'a 500',
|
||||
new ApiError(500),
|
||||
'Something went wrong',
|
||||
"The server couldn't finish. Try again. If it keeps happening, check the server log.",
|
||||
],
|
||||
[
|
||||
'a network error',
|
||||
new NetworkError(),
|
||||
'Something went wrong',
|
||||
"The server couldn't finish. Try again. If it keeps happening, check the server log.",
|
||||
],
|
||||
])('A purge request that errors: %s', async (_, failure, title, description) => {
|
||||
await startPurgeAll({ [PURGE]: () => Promise.reject(failure) })
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
expect(toastCalls()[0]).toMatchObject({ color: 'error', title, description })
|
||||
expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull()
|
||||
expect(tiles()).toHaveLength(4)
|
||||
expect(calls(STATS)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('A 401 is left to the API client', async () => {
|
||||
await startPurgeAll({ [PURGE]: () => Promise.reject(new ApiError(401)) })
|
||||
|
||||
expect(toastCalls()).toHaveLength(0)
|
||||
expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Cache screen, purging the variants of one image', () => {
|
||||
it('A row Purge sends the image scope and is done', async () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PURGE]: () =>
|
||||
Promise.resolve({
|
||||
scope: 'image',
|
||||
purged_files: 6,
|
||||
purged_bytes: 6082765,
|
||||
failed_files: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await click(buttonByLabel('Purge north-wall.jpg'))
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
expect(bodyOf(PURGE)).toEqual({ scope: 'image', image_id: 1 })
|
||||
expect(alertDialog()).toBeNull()
|
||||
expect(toastCalls()[0]).toMatchObject({
|
||||
title: 'Purged 6 files (5.8 MB) for north-wall.jpg',
|
||||
})
|
||||
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('While it runs the row shows a loader and the other Purge buttons stay enabled', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage({ routes: { [PURGE]: () => pending.promise } })
|
||||
|
||||
await click(buttonByLabel('Purge north-wall.jpg'))
|
||||
|
||||
expect(buttonByLabel('Purge north-wall.jpg')!.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(buttonByLabel('Purge lime-plaster.jpg')!.disabled).toBe(false)
|
||||
expect(findButton('Purge all variants')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('The image no longer exists', async () => {
|
||||
await mountPage({ routes: { [PURGE]: () => Promise.reject(new ApiError(404)) } })
|
||||
|
||||
await click(buttonByLabel('Purge north-wall.jpg'))
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
expect(toastCalls()[0]).toMatchObject({
|
||||
title: 'Not found',
|
||||
description: 'This variant or image no longer exists. Refresh the page.',
|
||||
})
|
||||
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('Failed', async () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PURGE]: () =>
|
||||
Promise.resolve({
|
||||
scope: 'image',
|
||||
purged_files: 4,
|
||||
purged_bytes: 1000,
|
||||
failed_files: 2,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await click(buttonByLabel('Purge north-wall.jpg'))
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
expect(toastCalls()[0]).toMatchObject({
|
||||
title: "Purge didn't finish",
|
||||
description: '4 of 6 files were removed. Try again.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Accessibility and vocabulary', () => {
|
||||
it('Roles, labels and vocabulary', async () => {
|
||||
await mountPage()
|
||||
|
||||
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
|
||||
expect(document.body.querySelectorAll('[role="tab"]')).toHaveLength(2)
|
||||
const text = document.body.textContent ?? ''
|
||||
for (const banned of ['thumbnail preset', 'version', 'rendition']) {
|
||||
expect(text).not.toContain(banned)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -166,19 +166,18 @@ describe('Rule: Header and tabs', () => {
|
||||
expect(tabs[0]!.className).toContain('font-medium')
|
||||
expect(iconOf(tabs[0]!)).toBe('i-lucide-layers')
|
||||
expect(iconOf(tabs[1]!)).toBe('i-lucide-database')
|
||||
expect(tabs[1]!.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(tabs[1]!.getAttribute('aria-selected')).toBe('false')
|
||||
expect(tabs[1]!.getAttribute('href')).toBe('/settings/media/cache')
|
||||
expect(tabs[1]!.className).toContain('text-toned')
|
||||
})
|
||||
|
||||
it('Activating Cache changes nothing', async () => {
|
||||
it('Activating Cache routes to the Cache page', async () => {
|
||||
await mountPage()
|
||||
const before = router.currentRoute.value.fullPath
|
||||
const cache = document.body.querySelectorAll<HTMLElement>('[role="tab"]')[1]!
|
||||
|
||||
await click(cache)
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(before)
|
||||
expect(cache.getAttribute('aria-selected')).toBe('false')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings/media/cache'))
|
||||
expect(calls(LIST)).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user