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