CI & Build / Python lint (push) Successful in 9s
CI & Build / Plugin hooks (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 1m7s
Components, stores, api clients, router, types and shared CSS now state what each piece is and why, without the milestone that introduced it. diff.ts names its consumers instead of the copy history; stores/notes and tasks state their current scope instead of the cleanup that set it; theme.css states the token practice instead of the sweep that established it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
389 lines
14 KiB
Vue
389 lines
14 KiB
Vue
<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: 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, never after the work,
|
|
// 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>
|