580 lines
17 KiB
Vue
580 lines
17 KiB
Vue
|
|
<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>
|
|||
|
|
|
|||
|
|
<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?.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><id>__<hash>.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>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 <token>"</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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 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"',
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
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 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() ]);
|
|||
|
|
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>
|