feat(523): Settings -> Data -> Scheduled backups - the verdict first, then destination, schedule, Test, Back up now and recent runs
BackupScheduleSettings.vue leads with whether the data is backed up (last good backup, or Overdue), then what is wrong (last failure in words, an S3 secret this SECRET_KEY cannot open, incomplete config), then the form: daily toggle and UTC hour, folder or S3 bucket, Test destination with unsaved values, Back up now (polls while running, re-armed in finally), and the last runs. The secret is write-only. formatBytes moves to utils/bytes.ts for both views. Restore from Backup now also takes the .tar, and says to use the CLI on a 413. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
import { apiGet, apiPost, apiPut } from "@/api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled backups (milestone 523) — `/api/admin/backups`. One dated,
|
||||||
|
* uncompressed tar per run, delivered to a folder or an S3 bucket.
|
||||||
|
*/
|
||||||
|
export interface BackupConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
/** UTC hour the daily backup runs at, 0–23. */
|
||||||
|
hour: number;
|
||||||
|
destination: "folder" | "s3";
|
||||||
|
folder_path: string;
|
||||||
|
s3_endpoint: string;
|
||||||
|
s3_bucket: string;
|
||||||
|
s3_access_key: string;
|
||||||
|
s3_region: string;
|
||||||
|
s3_verify_tls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupRun {
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string;
|
||||||
|
trigger: "scheduled" | "manual";
|
||||||
|
status: "ok" | "failed";
|
||||||
|
destination?: string;
|
||||||
|
file?: string;
|
||||||
|
size_bytes?: number;
|
||||||
|
where?: string;
|
||||||
|
error?: string;
|
||||||
|
duration_s: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupsOverview {
|
||||||
|
config: BackupConfig;
|
||||||
|
/** Never the secret itself: whether one is kept, and whether this
|
||||||
|
* instance can still open it. */
|
||||||
|
secret: { set: boolean; readable: boolean };
|
||||||
|
/** What stops the saved config from sending a backup, in words. */
|
||||||
|
problems: string[];
|
||||||
|
running: boolean;
|
||||||
|
/** Newest first, at most 30. */
|
||||||
|
runs: BackupRun[];
|
||||||
|
last_run: BackupRun | null;
|
||||||
|
last_success: BackupRun | null;
|
||||||
|
overdue: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the form sends: config fields, plus the secret when one was typed. */
|
||||||
|
export type BackupChanges = Partial<BackupConfig> & {
|
||||||
|
s3_secret?: string;
|
||||||
|
clear_s3_secret?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBackups(): Promise<BackupsOverview> {
|
||||||
|
return apiGet<BackupsOverview>("/api/admin/backups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBackups(changes: BackupChanges): Promise<BackupsOverview> {
|
||||||
|
return apiPut<BackupsOverview>("/api/admin/backups", changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask the destination whether it would take a backup, with the form's
|
||||||
|
* unsaved values. The server gives the destination 45s; this waits a little
|
||||||
|
* longer so its own answer, not the client's deadline, is what arrives. */
|
||||||
|
export function checkBackupDestination(
|
||||||
|
changes: BackupChanges,
|
||||||
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
|
return apiPost<{ ok: boolean; message: string }>("/api/admin/backups/check", changes, { timeoutMs: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runBackupNow(): Promise<{ status: string }> {
|
||||||
|
return apiPost<{ status: string }>("/api/admin/backups/run", {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import {
|
||||||
|
checkBackupDestination, getBackups, runBackupNow, saveBackups,
|
||||||
|
type BackupChanges, type BackupConfig, type BackupRun, type BackupsOverview,
|
||||||
|
} from "@/api/backups";
|
||||||
|
import { relativeTime } from "@/composables/useRelativeTime";
|
||||||
|
import { formatBytes } from "@/utils/bytes";
|
||||||
|
import { fmtStamp } from "@/utils/dateFormat";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled backups (milestone 523): one dated, uncompressed tar per run,
|
||||||
|
* delivered to a folder or an S3 bucket. The page answers "is my data backed
|
||||||
|
* up?" first — the verdict, then anything wrong — and the settings follow.
|
||||||
|
*/
|
||||||
|
const toastStore = useToastStore();
|
||||||
|
|
||||||
|
const data = ref<BackupsOverview | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const loadError = ref("");
|
||||||
|
const form = ref<BackupConfig | null>(null);
|
||||||
|
// The secret is never sent back by the server; this holds only what the
|
||||||
|
// admin types, and is sent only when non-empty.
|
||||||
|
const secretInput = ref("");
|
||||||
|
const saving = ref(false);
|
||||||
|
const checking = ref(false);
|
||||||
|
const checkResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||||
|
const starting = ref(false);
|
||||||
|
const showAllRuns = ref(false);
|
||||||
|
const RUNS_SHOWN = 8;
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let unmounted = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loadError.value = "";
|
||||||
|
try {
|
||||||
|
const fresh = await getBackups();
|
||||||
|
data.value = fresh;
|
||||||
|
// Keep the admin's unsaved edits; take the server's config only on the
|
||||||
|
// first load or right after a save.
|
||||||
|
if (!form.value) form.value = { ...fresh.config };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
loadError.value = apiErrorMessage(e, "Could not load the backup settings");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
schedulePoll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// While a backup runs, follow it. Re-armed from load()'s finally (rule 157),
|
||||||
|
// so one failed read does not stop the page following the run.
|
||||||
|
function schedulePoll() {
|
||||||
|
if (pollTimer) clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
if (!unmounted && data.value?.running) pollTimer = setTimeout(load, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unmounted = true;
|
||||||
|
if (pollTimer) clearTimeout(pollTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hourLabel = (h: number) => `${String(h).padStart(2, "0")}:00 UTC`;
|
||||||
|
|
||||||
|
// ── The verdict, and what is wrong ─────────────────────────────────────────
|
||||||
|
const verdict = computed(() => {
|
||||||
|
const d = data.value;
|
||||||
|
if (!d) return { tone: "", text: "" };
|
||||||
|
if (d.running) return { tone: "", text: "Backing up now…" };
|
||||||
|
if (!d.config.enabled) {
|
||||||
|
return { tone: "muted", text: "Scheduled backups are off. Nothing is backed up unless you run one." };
|
||||||
|
}
|
||||||
|
const ok = d.last_success;
|
||||||
|
if (d.overdue) {
|
||||||
|
return {
|
||||||
|
tone: "error",
|
||||||
|
text: ok
|
||||||
|
? `Overdue — the last backup that worked was ${relativeTime(ok.finished_at)} (${fmtStamp(ok.finished_at)}).`
|
||||||
|
: "Overdue — no backup has worked yet.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!ok) return { tone: "", text: `On — the first backup runs at ${hourLabel(d.config.hour)}.` };
|
||||||
|
return {
|
||||||
|
tone: "ok",
|
||||||
|
text: `Backed up ${relativeTime(ok.finished_at)}: ${ok.file}, ${formatBytes(ok.size_bytes ?? 0)}, to ${ok.where}.`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastFailure = computed(() => {
|
||||||
|
const last = data.value?.last_run;
|
||||||
|
return last && last.status === "failed" ? last : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretUnreadable = computed(() => {
|
||||||
|
const s = data.value?.secret;
|
||||||
|
return !!s && s.set && !s.readable;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleRuns = computed<BackupRun[]>(() => {
|
||||||
|
const runs = data.value?.runs ?? [];
|
||||||
|
return showAllRuns.value ? runs : runs.slice(0, RUNS_SHOWN);
|
||||||
|
});
|
||||||
|
const hiddenRuns = computed(() => Math.max(0, (data.value?.runs.length ?? 0) - RUNS_SHOWN));
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────────────────────
|
||||||
|
function changes(): BackupChanges {
|
||||||
|
const out: BackupChanges = { ...(form.value as BackupConfig) };
|
||||||
|
if (secretInput.value) out.s3_secret = secretInput.value;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
data.value = await saveBackups(changes());
|
||||||
|
form.value = { ...data.value.config };
|
||||||
|
secretInput.value = "";
|
||||||
|
toastStore.show("Backup settings saved");
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toastStore.show(apiErrorMessage(e, "Could not save the backup settings"), "error");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSecret() {
|
||||||
|
try {
|
||||||
|
data.value = await saveBackups({ clear_s3_secret: true });
|
||||||
|
toastStore.show("S3 secret removed");
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toastStore.show(apiErrorMessage(e, "Could not remove the secret"), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
if (!form.value) return;
|
||||||
|
checking.value = true;
|
||||||
|
checkResult.value = null;
|
||||||
|
try {
|
||||||
|
checkResult.value = await checkBackupDestination(changes());
|
||||||
|
} catch (e: unknown) {
|
||||||
|
checkResult.value = { ok: false, message: apiErrorMessage(e, "The check could not be run") };
|
||||||
|
} finally {
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backUpNow() {
|
||||||
|
starting.value = true;
|
||||||
|
try {
|
||||||
|
await runBackupNow();
|
||||||
|
if (data.value) data.value.running = true;
|
||||||
|
schedulePoll();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toastStore.show(apiErrorMessage(e, "Could not start a backup"), "error");
|
||||||
|
} finally {
|
||||||
|
starting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="backups">
|
||||||
|
<p v-if="loading && !data" class="state">Loading…</p>
|
||||||
|
<p v-else-if="loadError && !data" class="state state-error" role="alert">
|
||||||
|
{{ loadError }}
|
||||||
|
<button type="button" class="btn-text" @click="load">Retry</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else-if="data && form">
|
||||||
|
<p :class="['verdict', `verdict-${verdict.tone}`]">{{ verdict.text }}</p>
|
||||||
|
|
||||||
|
<p v-if="lastFailure && !data.running" class="problem" role="alert">
|
||||||
|
The last run failed {{ relativeTime(lastFailure.finished_at) }}: {{ lastFailure.error }}
|
||||||
|
</p>
|
||||||
|
<p v-if="secretUnreadable" class="problem" role="alert">
|
||||||
|
The stored S3 secret can't be opened here — it was saved on an instance with a different
|
||||||
|
SECRET_KEY. Enter it again below.
|
||||||
|
</p>
|
||||||
|
<ul v-if="data.config.enabled && data.problems.length" class="problem-list" role="alert">
|
||||||
|
<li v-for="p in data.problems" :key="p">{{ p }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="intro">
|
||||||
|
Each run adds one dated file, <code>scribe-<date>.tar</code>, holding every table
|
||||||
|
as plain lines. It is not compressed, so a deduplicating tool such as restic stores only what
|
||||||
|
changed since the day before. Scribe never deletes old backups — the destination keeps and
|
||||||
|
thins the history. Sharing, API keys and forge connections are not included.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="check">
|
||||||
|
<input v-model="form.enabled" type="checkbox" />
|
||||||
|
Back up daily
|
||||||
|
</label>
|
||||||
|
<label class="inline-field">
|
||||||
|
at
|
||||||
|
<select v-model.number="form.hour" class="fs-input" aria-label="Backup hour (UTC)">
|
||||||
|
<option v-for="h in 24" :key="h - 1" :value="h - 1">{{ hourLabel(h - 1) }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="destination">
|
||||||
|
<legend>Send each backup to</legend>
|
||||||
|
<label class="check">
|
||||||
|
<input v-model="form.destination" type="radio" value="folder" />
|
||||||
|
A folder
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input v-model="form.destination" type="radio" value="s3" />
|
||||||
|
An S3 bucket
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div v-if="form.destination === 'folder'" class="fields">
|
||||||
|
<label class="field">
|
||||||
|
<span>Folder</span>
|
||||||
|
<input v-model.trim="form.folder_path" class="fs-input" placeholder="/backups" />
|
||||||
|
<span class="field-hint">
|
||||||
|
A path inside the Scribe container — usually a share mounted there that a backup
|
||||||
|
service watches.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="fields">
|
||||||
|
<label class="field">
|
||||||
|
<span>Address</span>
|
||||||
|
<input v-model.trim="form.s3_endpoint" class="fs-input" placeholder="https://backup.example.com" />
|
||||||
|
<span class="field-hint">The S3 service itself, without the bucket. Buckets are addressed by path.</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Bucket</span>
|
||||||
|
<input v-model.trim="form.s3_bucket" class="fs-input" placeholder="scribe" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Access key</span>
|
||||||
|
<input v-model.trim="form.s3_access_key" class="fs-input" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Secret key</span>
|
||||||
|
<input
|
||||||
|
v-model="secretInput"
|
||||||
|
type="password"
|
||||||
|
class="fs-input"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="data.secret.set && data.secret.readable ? 'Saved — type a new one to replace it' : ''"
|
||||||
|
/>
|
||||||
|
<span class="field-hint">
|
||||||
|
Stored encrypted, and never included in a backup. A key that may only add files is enough.
|
||||||
|
<button
|
||||||
|
v-if="data.secret.set"
|
||||||
|
type="button"
|
||||||
|
class="btn-text"
|
||||||
|
@click="removeSecret"
|
||||||
|
>Remove the saved secret</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Region</span>
|
||||||
|
<input v-model.trim="form.s3_region" class="fs-input" placeholder="us-east-1" />
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input v-model="form.s3_verify_tls" type="checkbox" />
|
||||||
|
Check the address's TLS certificate
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn-primary" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? "Saving…" : "Save" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" :disabled="checking" @click="check">
|
||||||
|
{{ checking ? "Checking…" : "Test destination" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="starting || data.running"
|
||||||
|
@click="backUpNow"
|
||||||
|
>
|
||||||
|
{{ data.running ? "Backing up…" : "Back up now" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="checkResult" :class="['check-result', checkResult.ok ? 'check-ok' : 'check-bad']" role="status">
|
||||||
|
{{ checkResult.message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="runs-label">Recent runs</h3>
|
||||||
|
<p v-if="!data.runs.length" class="state">None yet.</p>
|
||||||
|
<div v-else class="runs-scroll">
|
||||||
|
<table class="runs">
|
||||||
|
<thead>
|
||||||
|
<tr><th>When</th><th>Result</th><th>File</th><th class="num">Size</th><th class="num">Took</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in visibleRuns" :key="r.started_at" :class="{ failed: r.status === 'failed' }">
|
||||||
|
<td :title="fmtStamp(r.started_at)">
|
||||||
|
{{ relativeTime(r.started_at) }}
|
||||||
|
<span v-if="r.trigger === 'manual'" class="by-hand">by hand</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.status === "ok" ? "Backed up" : r.error }}</td>
|
||||||
|
<td><code v-if="r.file">{{ r.file }}</code><span v-else>—</span></td>
|
||||||
|
<td class="num">{{ r.size_bytes != null ? formatBytes(r.size_bytes) : "—" }}</td>
|
||||||
|
<td class="num">{{ r.duration_s }} s</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button v-if="hiddenRuns && !showAllRuns" type="button" class="btn-text" @click="showAllRuns = true">
|
||||||
|
+{{ hiddenRuns }} earlier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backups { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||||
|
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
|
||||||
|
.state-error { color: var(--fs-error); }
|
||||||
|
|
||||||
|
.verdict { margin: 0; font-size: var(--fs-size-body); color: var(--fs-text-primary); }
|
||||||
|
.verdict-ok { color: var(--fs-success); }
|
||||||
|
.verdict-error { color: var(--fs-error); }
|
||||||
|
.verdict-muted { color: var(--fs-text-secondary); }
|
||||||
|
.problem, .problem-list {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--fs-error);
|
||||||
|
}
|
||||||
|
.problem-list { padding-left: var(--fs-space-4); }
|
||||||
|
|
||||||
|
.intro { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
|
||||||
|
.intro code, .runs code { font-family: var(--fs-font-mono); font-size: var(--fs-size-tiny); }
|
||||||
|
|
||||||
|
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-3); }
|
||||||
|
.check { display: inline-flex; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
|
||||||
|
.inline-field { display: inline-flex; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
|
||||||
|
.inline-field select { width: auto; }
|
||||||
|
|
||||||
|
.destination {
|
||||||
|
margin: 0; padding: 0; border: 0;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-3);
|
||||||
|
}
|
||||||
|
.destination legend {
|
||||||
|
float: left; margin-right: var(--fs-space-2);
|
||||||
|
font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields { display: flex; flex-direction: column; gap: var(--fs-space-3); max-width: 32rem; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: var(--fs-space-1); }
|
||||||
|
.field > span:first-child { font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
|
||||||
|
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||||
|
.check-result { margin: 0; font-size: var(--fs-size-body-sm); }
|
||||||
|
.check-ok { color: var(--fs-success); }
|
||||||
|
.check-bad { color: var(--fs-error); }
|
||||||
|
|
||||||
|
.runs-label {
|
||||||
|
margin: var(--fs-space-2) 0 0;
|
||||||
|
font-size: var(--fs-size-tiny); font-weight: normal;
|
||||||
|
text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.runs-scroll { overflow-x: auto; }
|
||||||
|
.runs { width: 100%; border-collapse: collapse; font-size: var(--fs-size-body-sm); }
|
||||||
|
.runs th {
|
||||||
|
text-align: left; font-weight: normal;
|
||||||
|
font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
|
||||||
|
padding: var(--fs-space-1) var(--fs-space-2);
|
||||||
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.runs td {
|
||||||
|
padding: var(--fs-space-1) var(--fs-space-2);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
.runs .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.runs tr.failed td { color: var(--fs-error); }
|
||||||
|
.by-hand { margin-left: var(--fs-space-1); font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** "1.4 MB" — a byte count for people. Binary units, one decimal under 10. */
|
||||||
|
export function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let v = n / 1024, i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import PaginationBar from "@/components/PaginationBar.vue";
|
|||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
import MomentsSettings from "@/components/MomentsSettings.vue";
|
import MomentsSettings from "@/components/MomentsSettings.vue";
|
||||||
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
|
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
|
||||||
|
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
|
||||||
|
import { formatBytes } from "@/utils/bytes";
|
||||||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||||
import { fetchVersion, type VersionPayload } from "@/api/version";
|
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||||||
|
|
||||||
@@ -640,13 +642,6 @@ const dbHealth = ref<DbHealth | null>(null);
|
|||||||
const loadingHealth = ref(false);
|
const loadingHealth = ref(false);
|
||||||
const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling behind
|
const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling behind
|
||||||
|
|
||||||
function formatBytes(n: number): string {
|
|
||||||
if (n < 1024) return `${n} B`;
|
|
||||||
const units = ["KB", "MB", "GB", "TB"];
|
|
||||||
let v = n / 1024, i = 0;
|
|
||||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
||||||
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
const pluginInstallCommands = computed(() => {
|
const pluginInstallCommands = computed(() => {
|
||||||
const mkt = (pluginMarketplaceUrl.value || '').trim()
|
const mkt = (pluginMarketplaceUrl.value || '').trim()
|
||||||
|| serverMarketplaceUrl.value
|
|| serverMarketplaceUrl.value
|
||||||
@@ -1368,14 +1363,21 @@ async function handleRestoreFile(event: Event) {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
restoring.value = true;
|
restoring.value = true;
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
// The scheduled backup's .tar goes up as it is; the JSON download is
|
||||||
const data = JSON.parse(text);
|
// parsed first so a damaged file fails here, before the upload.
|
||||||
|
const isTar = file.name.toLowerCase().endsWith(".tar");
|
||||||
const res = await fetch("/api/admin/restore", {
|
const res = await fetch("/api/admin/restore", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": isTar ? "application/x-tar" : "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: isTar ? file : JSON.stringify(JSON.parse(await file.text())),
|
||||||
signal: bulkDeadline(),
|
signal: bulkDeadline(),
|
||||||
});
|
});
|
||||||
|
if (res.status === 413) {
|
||||||
|
throw new Error(
|
||||||
|
"the file is bigger than the web upload allows. Restore it with " +
|
||||||
|
"`python -m scribe.restore <file>` inside the Scribe container instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||||||
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
||||||
@@ -2650,12 +2652,16 @@ async function deleteUser(userId: number) {
|
|||||||
<input
|
<input
|
||||||
ref="restoreFileInput"
|
ref="restoreFileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json"
|
accept=".json,.tar"
|
||||||
class="hidden-file-input"
|
class="hidden-file-input"
|
||||||
@change="handleRestoreFile"
|
@change="handleRestoreFile"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Scheduled backups</h2>
|
||||||
|
<BackupScheduleSettings />
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user