directus-extension-generate.../src/module/module.vue

640 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<private-view title="Image Previews">
<main class="ip">
<v-notice v-if="status && !status.enabled" type="warning">
Preview generation is disabled. Enable it in Configuration below.
</v-notice>
<v-notice v-if="status?.run?.error" type="danger">
Last run error: {{ status.run.error }}
</v-notice>
<section class="ip__card">
<header class="ip__head">
<h2>Backfill progress</h2>
<div class="ip__actions">
<v-button kind="primary" :loading="starting" @click="startRun">Run now</v-button>
<v-button v-if="status?.run?.running" kind="danger" @click="cancelRun">Cancel</v-button>
</div>
</header>
<p class="ip__hint">
“Run now” processes the whole library for all enabled presets and ignores the maintenance
window. Progress refreshes every 3 seconds.
</p>
<v-progress-linear :value="percent" />
<div class="ip__grid">
<div><span class="ip__label">done</span> {{ status?.run?.done ?? 0 }} / {{ status?.run?.total ?? 0 }}</div>
<div><span class="ip__label">remaining</span> {{ remaining }}</div>
<div><span class="ip__label">generated</span> {{ status?.run?.generated ?? 0 }}</div>
<div><span class="ip__label">already cached</span> {{ status?.run?.hits ?? 0 }}</div>
<div><span class="ip__label">failed</span> {{ status?.run?.failed ?? 0 }}</div>
<div><span class="ip__label">rate</span> {{ status?.run?.rateHuman ?? '—' }}</div>
<div><span class="ip__label">eta</span> {{ status?.run?.etaHuman ?? '—' }}</div>
<div><span class="ip__label">last started</span> {{ status?.run?.startedAtHuman ?? '—' }}</div>
<div><span class="ip__label">last finished</span> {{ status?.run?.finishedAtHuman ?? '—' }}</div>
</div>
<p class="ip__hint">
{{ status?.enabled ? 'enabled' : 'disabled' }} ·
{{ status?.run?.paused ? 'paused (window closed)' : status?.inWindow ? 'inside maintenance window' : 'outside maintenance window' }} ·
next window {{ status?.nextWindowHuman ?? '—' }}
</p>
</section>
<section class="ip__card">
<header class="ip__head">
<h2>Storage</h2>
<v-button @click="loadDisk">Refresh</v-button>
</header>
<table class="ip__table" v-if="disk">
<thead>
<tr><th></th><th>size</th><th>files</th></tr>
</thead>
<tbody>
<tr><td>Preview assets</td><td>{{ disk.previews.bytesHuman }}</td><td>{{ disk.previews.count }}</td></tr>
<tr><td>Original assets</td><td>{{ disk.originals.bytesHuman }}</td><td>{{ disk.originals.count }}</td></tr>
<tr><td><strong>Total</strong></td><td><strong>{{ disk.total.bytesHuman }}</strong></td><td><strong>{{ disk.total.count }}</strong></td></tr>
</tbody>
</table>
<p v-else class="ip__hint">Loading…</p>
<p class="ip__hint">Preview assets are the generated transforms (<code>&lt;id&gt;__&lt;hash&gt;.avif|webp</code>). Originals are the uploaded files.</p>
</section>
<section class="ip__card">
<header class="ip__head"><h2>Configuration</h2></header>
<form v-if="config" class="ip__form" @submit.prevent="saveConfig">
<v-checkbox v-model="config.enabled" label="Enabled" />
<p class="ip__hint">Master switch. When off, no previews are generated (upload hook and backfill are inert).</p>
<v-checkbox v-model="config.onUpload" label="Warm on upload" />
<p class="ip__hint">
Generate previews immediately after an image is uploaded, so admin thumbnails load
instantly. When off, previews are generated on first view instead.
</p>
<v-checkbox v-model="config.debug" label="Debug logging" />
<p class="ip__hint">
Write verbose generation logs to the Directus server log (container logs /
<code>logs/directus.log</code>).
</p>
<div class="ip__row">
<label class="ip__field">
<span>Maintenance window start</span>
<v-input v-model="config.windowStart" placeholder="01:00" />
</label>
<label class="ip__field">
<span>Maintenance window end</span>
<v-input v-model="config.windowEnd" placeholder="04:00" />
</label>
</div>
<p class="ip__hint">
Scheduled runs only start inside this window (local time). “Run now” ignores it.
Default: 01:00–04:00.
</p>
<label class="ip__field">
<span>Concurrency</span>
<v-input v-model="config.concurrency" type="number" />
</label>
<p class="ip__hint">How many previews are generated in parallel. Keep low on CPU-limited hosts.</p>
<label class="ip__field">
<span>Formats for “auto” presets</span>
<v-input v-model="formatsInput" placeholder="avif,webp" />
</label>
<p class="ip__hint">
Used only by presets whose format is <code>auto</code> (all system presets). Fixed-format
custom presets ignore this list and use their own format. One file is generated per
format listed here.
</p>
<div class="ip__actions">
<v-button kind="primary" @click="saveConfig">Save</v-button>
<span class="ip__hint">{{ message }}</span>
</div>
</form>
</section>
<section class="ip__card">
<header class="ip__head"><h2>Presets</h2></header>
<p class="ip__hint">
System presets are always generated with their built-in sizes (64 / 300 / 800 px,
cover/contain). Custom presets from Settings → File Storage are added automatically;
“Run now” also generates them for existing images.
</p>
<v-notice v-if="shadowedKeys.length" type="warning">
These custom preset keys are shadowed by a system preset and will not be used:
{{ shadowedKeys.join(', ') }}
</v-notice>
<table class="ip__table">
<thead>
<tr><th>key</th><th>format</th><th>generated formats</th><th>source</th></tr>
</thead>
<tbody>
<tr v-for="preset in presets" :key="preset.key">
<td class="ip__mono">{{ preset.key }}</td>
<td>{{ preset.format ?? 'original' }}</td>
<td>{{ preset.effectiveFormats.map((f) => f ?? 'original').join(', ') }}</td>
<td>{{ preset.system ? 'system' : 'custom' }}</td>
</tr>
</tbody>
</table>
</section>
<section class="ip__card">
<header class="ip__head"><h2>Diagnostics</h2></header>
<div class="ip__row">
<label class="ip__field">
<span>File id</span>
<v-input v-model="fileId" placeholder="file uuid" />
</label>
<label class="ip__field">
<span>Preset</span>
<v-select v-model="selectedKey" :items="presetItems" placeholder="Select a preset" />
</label>
</div>
<div class="ip__actions">
<v-button :disabled="!fileId || !selectedKey" @click="resolvePlan">Resolve plan</v-button>
<v-button :disabled="!fileId || !selectedKey" @click="generate(true)">Dry run</v-button>
<v-button kind="primary" :disabled="!fileId || !selectedKey" @click="generate(false)">Generate</v-button>
</div>
<p class="ip__hint">
“Resolve plan” predicts the exact cache filename without writing anything — use it to
verify a custom preset maps to the file the browser will request.
</p>
<pre v-if="plan" class="ip__pre">{{ JSON.stringify(plan, null, 2) }}</pre>
</section>
<section class="ip__card">
<header class="ip__head">
<h2>Recent operations</h2>
<v-button @click="loadStatus">Refresh</v-button>
</header>
<table class="ip__table">
<thead>
<tr><th>time</th><th>file</th><th>key</th><th>format</th><th>ok</th><th>duration</th><th>error</th></tr>
</thead>
<tbody>
<tr v-for="(op, index) in ops" :key="index">
<td>{{ op.tsHuman }}</td>
<td class="ip__mono">{{ op.fileId.slice(0, 8) }}</td>
<td>{{ op.key }}</td>
<td>{{ op.format ?? 'original' }}</td>
<td>{{ op.ok ? '✓' : '✗' }}</td>
<td>{{ op.msHuman }}</td>
<td class="ip__error">{{ op.error ?? '' }}</td>
</tr>
</tbody>
</table>
</section>
<section class="ip__card">
<header class="ip__head">
<h2>Failed previews</h2>
<v-button @click="loadFailures">Refresh</v-button>
</header>
<p class="ip__hint">
Persisted failure journal. Retries run automatically inside the maintenance window with no
active backfill: up to 3 attempts, then 5 and 15 minute backoff.
</p>
<table class="ip__table">
<thead>
<tr><th>file</th><th>preset</th><th>format</th><th>attempts</th><th>last error</th><th>next retry</th><th>status</th></tr>
</thead>
<tbody>
<tr v-for="failure in failures" :key="failure.file_id + failure.preset_key + (failure.format ?? '')">
<td class="ip__mono">{{ failure.file?.filename_download ?? failure.file_id.slice(0, 8) }}</td>
<td>{{ failure.preset_key }}</td>
<td>{{ failure.format ?? 'original' }}</td>
<td>{{ failure.attempts }}</td>
<td class="ip__error">{{ failure.error ?? '' }}</td>
<td>{{ failure.next_retry_at_human ?? '—' }}</td>
<td>{{ failure.status }}</td>
</tr>
</tbody>
</table>
<p v-if="failures.length === 0" class="ip__hint">No failures recorded.</p>
</section>
<section class="ip__card">
<header class="ip__head"><h2>API reference</h2></header>
<p class="ip__hint">
Base path <code>/image-previews</code>. Every endpoint requires an admin session or an
admin token: <code>-H "Authorization: Bearer &lt;token&gt;"</code>.
</p>
<details v-for="endpoint in endpoints" :key="endpoint.method + endpoint.path" class="ip__details">
<summary><code>{{ endpoint.method }} {{ endpoint.path }}</code> — {{ endpoint.summary }}</summary>
<p class="ip__hint">{{ endpoint.description }}</p>
<pre class="ip__pre">{{ endpoint.example }}</pre>
</details>
</section>
</main>
</private-view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
interface Bucket {
bytes: number;
count: number;
bytesHuman: string;
}
interface RunState {
running: boolean;
done: number;
total: number;
generated: number;
hits: number;
failed: number;
rateHuman: string;
etaHuman: string;
startedAtHuman: string | null;
finishedAtHuman: string | null;
paused?: boolean;
error?: string;
}
interface Status {
enabled: boolean;
inWindow: boolean;
nextWindowHuman: string | null;
run: RunState | null;
}
interface PresetInfo {
key: string;
format: string | null;
effectiveFormats: (string | null)[];
system: boolean;
enabled: boolean;
}
interface FailureRecord {
file_id: string;
preset_key: string;
format: string | null;
attempts: number;
status: string;
error: string | null;
next_retry_at_human: string | null;
file: { filename_download: string | null; type: string | null; folder: string | null } | null;
}
const base = '/image-previews';
const status = ref<Status | null>(null);
const config = ref<Record<string, any> | null>(null);
const disk = ref<{ previews: Bucket; originals: Bucket; total: Bucket } | null>(null);
const ops = ref<any[]>([]);
const failures = ref<FailureRecord[]>([]);
const presets = ref<PresetInfo[]>([]);
const shadowedKeys = ref<string[]>([]);
const fileId = ref('');
const selectedKey = ref('');
const plan = ref<any>(null);
const message = ref('');
const formatsInput = ref('');
const starting = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
const presetItems = computed(() => presets.value.map((preset) => ({ text: preset.key, value: preset.key })));
const percent = computed(() => {
const run = status.value?.run;
if (!run || !run.total) return 0;
return Math.min(100, Math.round((run.done / run.total) * 100));
});
const remaining = computed(() => {
const run = status.value?.run;
if (!run) return 0;
return Math.max(0, run.total - run.done);
});
const endpoints = [
{
method: 'GET',
path: '/status',
summary: 'current run + window state',
description: 'Progress counters, rate, ETA, maintenance-window state, and human-readable dates.',
example: 'curl -H "Authorization: Bearer $TOKEN" $BASE/image-previews/status',
},
{
method: 'GET',
path: '/config',
summary: 'read settings',
description: 'Returns the effective settings (env defaults merged with the persisted config).',
example: 'curl -H "Authorization: Bearer $TOKEN" $BASE/image-previews/config',
},
{
method: 'PUT',
path: '/config',
summary: 'update settings',
description: 'Persists enabled, windowStart/windowEnd, concurrency, presetKeys, formats, onUpload, debug.',
example:
'curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \\\n -d \'{"enabled":true,"windowStart":"01:00","windowEnd":"04:00","concurrency":1,"formats":["avif","webp"],"onUpload":true,"debug":false,"presetKeys":null}\' \\\n $BASE/image-previews/config',
},
{
method: 'GET',
path: '/presets',
summary: 'list presets',
description: 'System + custom presets with the formats each one generates; flags custom keys shadowed by system presets.',
example: 'curl -H "Authorization: Bearer $TOKEN" $BASE/image-previews/presets',
},
{
method: 'POST',
path: '/run',
summary: 'start a backfill',
description: 'Body { "ignoreWindow": true } runs immediately; omit it to respect the maintenance window. Optional { "presets": ["key"] }.',
example: 'curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \\\n -d \'{"ignoreWindow":true}\' $BASE/image-previews/run',
},
{
method: 'POST',
path: '/run/cancel',
summary: 'cancel a running backfill',
description: 'Sets a cancel flag; the backfill stops after the current file.',
example: 'curl -X POST -H "Authorization: Bearer $TOKEN" $BASE/image-previews/run/cancel',
},
{
method: 'GET',
path: '/disk-usage',
summary: 'preview vs original storage',
description: 'Bytes and file counts for preview assets, original assets, and the total (machine + human-readable).',
example: 'curl -H "Authorization: Bearer $TOKEN" $BASE/image-previews/disk-usage',
},
{
method: 'POST',
path: '/generate',
summary: 'generate one file',
description: 'Body { fileId, keys?, formats?, dryRun? }. Returns per preset/format results, filenames, sizes and durations.',
example: 'curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \\\n -d \'{"fileId":"<uuid>","keys":["system-medium-cover"],"dryRun":true}\' $BASE/image-previews/generate',
},
{
method: 'GET',
path: '/resolve-plan',
summary: 'predict a cache filename',
description: 'Returns resolved transforms and the exact cache filename without writing anything.',
example: 'curl -H "Authorization: Bearer $TOKEN" "$BASE/image-previews/resolve-plan?fileId=<uuid>&key=system-medium-cover"',
},
{
method: 'GET',
path: '/ops',
summary: 'recent operations',
description: 'In-memory ring buffer of the last 200 generation attempts with timing and error messages.',
example: 'curl -H "Authorization: Bearer $TOKEN" "$BASE/image-previews/ops?limit=50"',
},
{
method: 'GET',
path: '/failures',
summary: 'persisted failures',
description: 'Failure journal records with file metadata, attempts, timestamps, next retry and status. Filter with status=failed|recovered|retry, limit (max 200) and offset.',
example: 'curl -H "Authorization: Bearer $TOKEN" "$BASE/image-previews/failures?status=failed&limit=50&offset=0"',
},
];
async function api(path: string, init?: RequestInit) {
const response = await fetch(`${base}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
...init,
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return response.json();
}
async function loadStatus() {
try {
status.value = await api('/status');
ops.value = (await api('/ops?limit=50')).ops;
} catch {
/* ignore polling errors */
}
}
async function loadConfig() {
config.value = await api('/config');
formatsInput.value = (config.value?.formats ?? []).join(',');
}
async function loadDisk() {
disk.value = await api('/disk-usage');
}
async function loadPresets() {
const result = await api('/presets');
presets.value = result.presets;
shadowedKeys.value = result.shadowedKeys ?? [];
}
async function loadFailures() {
try {
const result = await api('/failures?limit=100');
failures.value = result.records ?? [];
} catch {
/* ignore polling errors */
}
}
async function saveConfig() {
if (!config.value) return;
try {
const payload = {
...config.value,
concurrency: Number(config.value.concurrency),
formats: formatsInput.value.split(',').map((part) => part.trim()).filter(Boolean),
};
config.value = await api('/config', { method: 'PUT', body: JSON.stringify(payload) });
formatsInput.value = (config.value?.formats ?? []).join(',');
message.value = 'saved';
await loadStatus();
} catch (error) {
message.value = String(error);
}
}
async function startRun() {
starting.value = true;
message.value = '';
try {
await api('/run', { method: 'POST', body: JSON.stringify({ ignoreWindow: true }) });
message.value = 'backfill started';
await loadStatus();
} catch (error) {
message.value = String(error);
} finally {
starting.value = false;
}
}
async function cancelRun() {
try {
await api('/run/cancel', { method: 'POST', body: '{}' });
message.value = 'cancelling after the current file';
} catch (error) {
message.value = String(error);
}
}
async function resolvePlan() {
try {
plan.value = await api(
`/resolve-plan?fileId=${encodeURIComponent(fileId.value)}&key=${encodeURIComponent(selectedKey.value)}`,
);
} catch (error) {
message.value = String(error);
}
}
async function generate(dryRun: boolean) {
try {
plan.value = await api('/generate', {
method: 'POST',
body: JSON.stringify({ fileId: fileId.value, keys: [ selectedKey.value ], dryRun }),
});
await loadStatus();
await loadDisk();
} catch (error) {
message.value = String(error);
}
}
onMounted(async () => {
await Promise.all([ loadStatus(), loadConfig(), loadDisk(), loadPresets(), loadFailures() ]);
timer = setInterval(loadStatus, 3000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
</script>
<style scoped>
.ip {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
max-width: 1100px;
}
.ip__card {
border: 1px solid var(--border-normal);
border-radius: 8px;
padding: 16px;
}
.ip__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.ip__head h2 {
font-size: 15px;
font-weight: 600;
margin: 0;
}
.ip__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ip__row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.ip__field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
}
.ip__form {
display: flex;
flex-direction: column;
gap: 6px;
}
.ip__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin: 12px 0;
font-variant-numeric: tabular-nums;
}
.ip__label {
color: var(--foreground-subdued);
margin-right: 4px;
}
.ip__hint {
color: var(--foreground-subdued);
font-size: 13px;
margin: 2px 0 6px;
}
.ip__pre {
background: var(--background-subdued);
border-radius: 6px;
padding: 12px;
overflow: auto;
max-height: 320px;
font-size: 12px;
white-space: pre-wrap;
}
.ip__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.ip__table th,
.ip__table td {
text-align: left;
padding: 4px 8px;
border-bottom: 1px solid var(--border-subdued);
vertical-align: top;
}
.ip__mono {
font-family: monospace;
}
.ip__error {
color: var(--danger);
max-width: 320px;
word-break: break-word;
}
.ip__details {
border-bottom: 1px solid var(--border-subdued);
padding: 6px 0;
}
.ip__details summary {
cursor: pointer;
}
</style>