470 lines
13 KiB
Vue
470 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { formatNumber, t } from '@/i18n'
|
|
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
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 NoAccessState from '@/components/NoAccessState.vue'
|
|
import ReadOnlyBanner from '@/components/ReadOnlyBanner.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 { usePermissionsStore } from '@/stores/permissions'
|
|
import { useVariantCacheStore } from '@/stores/variantCache'
|
|
import { useIsDesktop } from '@/composables/useIsDesktop'
|
|
|
|
const SKELETON_DELAY_MS = 150
|
|
const NOT_FOUND = 404
|
|
const CONFIRM_WORD = 'purge'
|
|
|
|
const session = useSessionStore()
|
|
const permissions = usePermissionsStore()
|
|
const store = useVariantCacheStore()
|
|
const overlay = useLocalizedOverlay()
|
|
const toast = useLocalizedToast()
|
|
const { announceFailure, announceSuccess } = useVariantToasts()
|
|
const isDesktop = useIsDesktop('40rem')
|
|
|
|
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(() => permissions.can('settings.view'))
|
|
const canEdit = computed(() => permissions.can('settings.edit_general'))
|
|
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(
|
|
() =>
|
|
!canEdit.value || !ready.value || store.isPurging || (stats.value?.unused.files ?? 0) === 0,
|
|
)
|
|
const purgeAllDisabled = computed(
|
|
() =>
|
|
!canEdit.value ||
|
|
!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>
|
|
|
|
<NoAccessState v-if="forbidden" />
|
|
|
|
<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/7 font-semibold text-highlighted">
|
|
{{ 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>
|
|
|
|
<ReadOnlyBanner
|
|
v-if="!canEdit"
|
|
:title="t('You can read these settings but not change them')"
|
|
:description="
|
|
t('Ask the owner to change them, or ask for a role that can edit settings.')
|
|
"
|
|
/>
|
|
|
|
<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>
|