Scheduled backups to a folder or S3 bucket, and forge tokens sealed at rest (milestone 523) #210
@@ -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 MomentsSettings from "@/components/MomentsSettings.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 { fetchVersion, type VersionPayload } from "@/api/version";
|
||||
|
||||
@@ -640,13 +642,6 @@ const dbHealth = ref<DbHealth | null>(null);
|
||||
const loadingHealth = ref(false);
|
||||
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 mkt = (pluginMarketplaceUrl.value || '').trim()
|
||||
|| serverMarketplaceUrl.value
|
||||
@@ -1368,14 +1363,21 @@ async function handleRestoreFile(event: Event) {
|
||||
if (!file) return;
|
||||
restoring.value = true;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
// The scheduled backup's .tar goes up as it is; the JSON download is
|
||||
// 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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": isTar ? "application/x-tar" : "application/json" },
|
||||
body: isTar ? file : JSON.stringify(JSON.parse(await file.text())),
|
||||
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) {
|
||||
const body = await res.json().catch(() => ({ 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
|
||||
ref="restoreFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
accept=".json,.tar"
|
||||
class="hidden-file-input"
|
||||
@change="handleRestoreFile"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section full-width">
|
||||
<h2>Scheduled backups</h2>
|
||||
<BackupScheduleSettings />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user